From c33cf33fe0ba5faa31e15fea0397bd3a7422bce3 Mon Sep 17 00:00:00 2001 From: Ikki Nishihara Date: Sat, 10 Oct 2026 17:29:44 +0900 Subject: [PATCH 001/144] AAF: a weak-reference collection with a zero key size can't exhaust memory An index that declares a key size of 0 made the importer loop `count` times (a u32 from the file) pushing an empty key each time. Reject a non-empty collection with key size 0, and require that count * size fits in the index stream before the loop starts. Co-Authored-By: Claude Sonnet 5.5 --- crates/interchange/src/aaf/hostile_tests.rs | 69 +++++++++++++++++++++ crates/interchange/src/aaf/mod.rs | 2 + crates/interchange/src/aaf/store.rs | 7 +++ 3 files changed, 78 insertions(+) create mode 100644 crates/interchange/src/aaf/hostile_tests.rs diff --git a/crates/interchange/src/aaf/hostile_tests.rs b/crates/interchange/src/aaf/hostile_tests.rs new file mode 100644 index 00000000..bd6dd6d6 --- /dev/null +++ b/crates/interchange/src/aaf/hostile_tests.rs @@ -0,0 +1,69 @@ +//! Hostile object trees: a crafted file must give an error, never a crash, a hang or an +//! allocation sized by a count in the file (AGENTS.md §0). + +use filmcraft_cfb::{Version, Writer}; + +use super::store::{self, SF_WEAK_SET, SF_WEAK_VECTOR, Value, utf16z}; + +const PID: u16 = 0x0007; + +/// A root object whose one property `PID` is a weak reference vector or set (stored form `sf`) +/// named `Keys`, with `index` as its `Keys index` stream. +fn weak_collection_file(sf: u16, index: Vec) -> Vec { + let name = utf16z("Keys"); + // byte order 'L', format version, 1 entry { pid, stored form, value length }, the value + let mut props = vec![0x4C, 0x20]; + for x in [1, PID, sf, name.len() as u16] { + props.extend_from_slice(&x.to_le_bytes()); + } + props.extend_from_slice(&name); + let mut w = Writer::new(Version::V3); + w.stream(Writer::ROOT, "properties", props).unwrap(); + w.stream(Writer::ROOT, "Keys index", index).unwrap(); + w.finish() +} + +/// A weak collection index: u32 count, u16 tag, u16 key pid, u8 key size, then the keys. +fn index(count: u32, size: u8, keys: &[u8]) -> Vec { + let mut v = count.to_le_bytes().to_vec(); + v.extend_from_slice(&0u16.to_le_bytes()); + v.extend_from_slice(&0x1B01u16.to_le_bytes()); + v.push(size); + v.extend_from_slice(keys); + v +} + +fn read_keys(sf: u16, index: Vec) -> Result>, String> { + match store::read(&weak_collection_file(sf, index))?.get(PID) { + Some(Value::WeakVec(_, _, keys)) => Ok(keys.clone()), + other => panic!("not a weak collection: {other:?}"), + } +} + +/// With a key size of 0 every key fits, so the count alone drove the loop: up to 4.29 billion +/// empty keys (about 100 GB) from a 9-byte index. A million is enough to show it, cheaply. +#[test] +fn zero_size_weak_collection_keys_are_an_error() { + for sf in [SF_WEAK_VECTOR, SF_WEAK_SET] { + match read_keys(sf, index(1_000_000, 0, &[])) { + Err(e) => assert!(e.contains("zero-size"), "{e}"), + Ok(keys) => panic!("read {} empty keys instead of an error", keys.len()), + } + } +} + +#[test] +fn weak_collection_count_beyond_the_index_is_an_error() { + for sf in [SF_WEAK_VECTOR, SF_WEAK_SET] { + let e = read_keys(sf, index(u32::MAX, 4, &[1, 2, 3, 4, 5, 6, 7, 8])).unwrap_err(); + assert!(e.contains("short weak collection index"), "{e}"); + } +} + +#[test] +fn weak_collections_within_the_index_are_read() { + for sf in [SF_WEAK_VECTOR, SF_WEAK_SET] { + assert_eq!(read_keys(sf, index(0, 0, &[])).unwrap(), Vec::>::new()); + assert_eq!(read_keys(sf, index(2, 4, &[1, 2, 3, 4, 5, 6, 7, 8])).unwrap(), [vec![1, 2, 3, 4], vec![5, 6, 7, 8]]); + } +} diff --git a/crates/interchange/src/aaf/mod.rs b/crates/interchange/src/aaf/mod.rs index 707c6a43..d8896b3d 100644 --- a/crates/interchange/src/aaf/mod.rs +++ b/crates/interchange/src/aaf/mod.rs @@ -86,5 +86,7 @@ pub fn sniff(bytes: &[u8]) -> bool { } } +#[cfg(test)] +mod hostile_tests; #[cfg(test)] mod tests; diff --git a/crates/interchange/src/aaf/store.rs b/crates/interchange/src/aaf/store.rs index c89b143d..815939a6 100644 --- a/crates/interchange/src/aaf/store.rs +++ b/crates/interchange/src/aaf/store.rs @@ -468,6 +468,13 @@ impl R<'_> { return Err(bad("short weak collection index")); }; let size = size as usize; + if count > 0 && size == 0 { + return Err(bad("zero-size weak collection key")); + } + // every key must lie in the index, so its length (not the count) bounds the loop + if (count as usize).checked_mul(size).and_then(|n| n.checked_add(9)).is_none_or(|end| end > idx.len()) { + return Err(bad("short weak collection index")); + } let mut keys = Vec::new(); for k in 0..count as usize { keys.push(idx.get(9 + k * size..9 + (k + 1) * size).ok_or_else(|| bad("short weak collection index"))?.to_vec()); From c34090f2d2798ac39cdc312a7d764f99114414f2 Mon Sep 17 00:00:00 2001 From: Ikki Nishihara Date: Sat, 10 Oct 2026 17:34:16 +0900 Subject: [PATCH 002/144] CFB: bound the DIFAT and FAT chain walks by the file's own size The DIFAT guard came from the header's sector count and the chain guard from the FAT length, both of which a file can inflate. A chain cannot have more sectors than the file holds: bound both walks by that. Co-Authored-By: Claude Sonnet 5.5 --- crates/cfb/src/read.rs | 15 ++++--- crates/cfb/src/tests.rs | 92 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 5 deletions(-) diff --git a/crates/cfb/src/read.rs b/crates/cfb/src/read.rs index 58c300b5..5ba3ed68 100644 --- a/crates/cfb/src/read.rs +++ b/crates/cfb/src/read.rs @@ -84,7 +84,6 @@ impl<'a> CompoundFile<'a> { let mini_cutoff = u32_at(data, 56) as u64; let first_minifat = u32_at(data, 60); let first_difat = u32_at(data, 68); - let n_difat = u32_at(data, 72) as usize; let mut header_clsid = [0u8; 16]; header_clsid.copy_from_slice(&data[8..24]); let max_sectors = data.len() / sector_size + 1; @@ -108,8 +107,10 @@ impl<'a> CompoundFile<'a> { let mut seen = 0; while d <= MAXREGSECT && fat_sectors.len() < n_fat { seen += 1; - if seen > n_difat.max(1) + max_sectors { - return Err(invalid("DIFAT chain loops")); + // a DIFAT chain cannot have more sectors than the file holds; the header's DIFAT sector + // count (up to 2^32 - 1) is not trusted, or a self-pointing sector loops that often + if seen > max_sectors { + return Err(invalid(format!("DIFAT chain loops: more than {max_sectors} sectors in a {}-byte file", data.len()))); } let s = cf.sector(d)?; let per = sector_size / 4 - 1; @@ -169,13 +170,17 @@ impl<'a> CompoundFile<'a> { let mut out = Vec::new(); let mut s = start; let mut steps = 0usize; + // A chain cannot visit more sectors than the file holds. Not `self.fat.len()`: the FAT can + // be far longer (one FAT sector named by many header / DIFAT slots), and a looping chain + // would then append a whole sector per FAT entry (hundreds of MB, or an abort). + let max_steps = self.data.len() / self.sector_size + 1; while s != ENDOFCHAIN { if s > MAXREGSECT { return Err(invalid(format!("bad sector {s:#x} in a chain"))); } steps += 1; - if steps > self.fat.len().max(1) { - return Err(invalid("sector chain loops")); + if steps > max_steps { + return Err(invalid(format!("sector chain loops: more than {max_steps} sectors in a {}-byte file", self.data.len()))); } let b = self.sector(s)?; out.extend_from_slice(b); diff --git a/crates/cfb/src/tests.rs b/crates/cfb/src/tests.rs index cdcb62db..7e9854fe 100644 --- a/crates/cfb/src/tests.rs +++ b/crates/cfb/src/tests.rs @@ -199,3 +199,95 @@ fn mutation_never_panics_or_hangs() { } } } + +fn put_u32(b: &mut [u8], at: usize, v: u32) { + b[at..at + 4].copy_from_slice(&v.to_le_bytes()); +} + +/// A 512-byte header (§2.2) with sector shift `shift`: no directory, mini FAT or DIFAT chain, and +/// all 109 FAT slots free. Tests fill in the fields they attack. +fn bare_header(shift: u16) -> Vec { + let mut h = vec![0u8; 512]; + h[..8].copy_from_slice(&SIGNATURE); + h[24..26].copy_from_slice(&0x3Eu16.to_le_bytes()); + h[26..28].copy_from_slice(&(if shift == 9 { 3u16 } else { 4 }).to_le_bytes()); + h[28..30].copy_from_slice(&0xFFFEu16.to_le_bytes()); + h[30..32].copy_from_slice(&shift.to_le_bytes()); + h[32..34].copy_from_slice(&6u16.to_le_bytes()); + for at in [48, 60, 68] { + put_u32(&mut h, at, ENDOFCHAIN); + } + put_u32(&mut h, 56, MINI_STREAM_CUTOFF); + h[76..].fill(0xFF); + h +} + +/// A DIFAT sector that names no FAT sector and points to itself. The loop guard used to be the +/// header's DIFAT sector count (up to 2^32 - 1), so this 1 KB file kept `open` (and so AAF +/// sniffing) busy for 14-16 s here (minutes with `u32::MAX`), reading a sector each pass. A DIFAT +/// chain cannot be longer than the file, so the walk now stops after as many passes as the file +/// has sectors (1024 / 512 + 1 = 3 here) and fails on the 4th. +#[test] +fn difat_self_loop_fails_fast() { + let mut b = bare_header(9); + put_u32(&mut b, 44, 1); // one FAT sector, to be found through the DIFAT + put_u32(&mut b, 68, 0); // first DIFAT sector + put_u32(&mut b, 72, 100_000_000); // claimed DIFAT sector count (inflates the old loop bound) + let mut difat = vec![0xFF; 512]; // no FAT sector numbers... + put_u32(&mut difat, 508, 0); // ...and the next DIFAT sector is this one + b.extend_from_slice(&difat); + // The message names the bound the walk stopped at: the file's 3 sectors, not the header's + // 100,000,000 (the old guard allowed 100,000,003 passes). + let expect = format!("DIFAT chain loops: more than 3 sectors in a {}-byte file", b.len()); + assert_eq!(CompoundFile::open(&b).unwrap_err(), Error::Invalid(expect)); +} + +/// All 109 header slots name the same FAT sector, so the FAT is 109 copies of it, and the +/// directory chain loops (sector 0 → 0). The loop guard used to be the FAT length, so this 450 KB +/// file appended a sector per FAT entry before failing: a 457 MB buffer per open (117 GB with +/// 64 KB sectors in a 7 MB file). A chain cannot visit more sectors than the file has. +#[test] +fn duplicated_fat_sector_chain_loop_fails_fast() { + let ss = 4096; + let mut b = bare_header(12); + put_u32(&mut b, 44, 109); // FAT sectors: the 109 header slots... + for i in 0..109 { + put_u32(&mut b, 76 + i * 4, 0); // ...all naming sector 0 + } + put_u32(&mut b, 48, 0); // the directory starts at sector 0 + b.resize(ss, 0); + let mut fat = vec![0xFF; ss]; + put_u32(&mut fat, 0, 0); // FAT[0] = 0: sector 0 is followed by itself + b.extend_from_slice(&fat); + b.resize(110 * ss, 0); // big enough for 109 FAT sectors to pass the size check + // The message names the bound the walk stopped at: 111, this file's 110 sectors + 1, not the + // 109 * 1024 = 111,616 entries of the inflated FAT that the old guard allowed (a 457 MB buffer). + let expect = format!("sector chain loops: more than 111 sectors in a {}-byte file", b.len()); + assert_eq!(CompoundFile::open(&b).unwrap_err(), Error::Invalid(expect)); +} + +/// Positive control for the chain bound: the longest chains a file can hold still read back, in +/// both versions: a directory (read without a size limit) and a stream, each filling every sector +/// but the header and the FAT (and the directory, for the stream). +#[test] +fn chains_filling_the_file_still_read() { + for v in [Version::V3, Version::V4] { + let ss = v_sector(v); + let mut w = Writer::new(v); + for i in 0..1000 { + w.stream(Writer::ROOT, &format!("e{i}"), Vec::new()).unwrap(); + } + let b = w.finish(); + assert!(b.len() / ss <= (1001 * 128usize).div_ceil(ss) + 3, "header, directory, FAT"); + let cf = CompoundFile::open(&b).unwrap(); + assert_eq!(cf.root().children.len(), 1000); + assert_eq!(cf.read_path("e999").unwrap(), Vec::::new()); + + let mut w = Writer::new(v); + let data = pattern(64 * ss, 3); + w.stream(Writer::ROOT, "big", data.clone()).unwrap(); + let b = w.finish(); + assert_eq!(b.len(), (1 + 64 + 2) * ss, "header, stream, directory, FAT"); + assert_eq!(CompoundFile::open(&b).unwrap().read_path("big").unwrap(), data); + } +} From fb41e56195dd977d8ec93492cc2e76e3113cdc0f Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:47:24 +0700 Subject: [PATCH 003/144] fix(i18n): prioritize Japanese system font ahead of Chinese fallback (#547) --- crates/ui-egui/src/i18n/mod.rs | 54 ++++++++++++++++++++++++++++------ 1 file changed, 45 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index c04416c6..ef4aeaf7 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -222,21 +222,34 @@ pub fn craft_japanese_font() -> bool { .any(|f| f.info.origin == filmcraft_text::fonts::CRAFT_ORIGIN && JAPANESE_SAMPLE.chars().all(|c| f.has_char(c))) } +/// Put the system Japanese face before the Chinese fallback, but after the app's Latin faces. +/// Installing it repeatedly must not accumulate duplicate entries in any UI font family. +fn prioritize_system_japanese(fonts: &mut egui::FontDefinitions) { + for family in crate::theme::font_families() { + let stack = fonts.families.entry(family).or_default(); + stack.retain(|name| name != JAPANESE_FONT); + let before_chinese = stack + .iter() + .position(|name| name == "system-chinese" || name.starts_with("craft:")) + .unwrap_or(stack.len()); + stack.insert(before_chinese, JAPANESE_FONT.into()); + } +} + /// Japanese for the interface: true when built with the craft-fonts (already installed by -/// `theme::install`). Otherwise add the system's Japanese font as the last fallback of every theme font family, from the next -/// pass on. Returns false (and changes nothing) when no Japanese font is installed. Call it again -/// after `theme::install`, which replaces the font definitions. +/// `theme::install`). Otherwise put the installed system Japanese face before the Chinese +/// fallbacks, preserving Latin fonts and the Chinese fallback for characters it alone covers. +/// Returns false when no Japanese font is installed; reapply after `theme::install` resets fonts. pub fn install_japanese_font(ctx: &egui::Context) -> bool { if craft_japanese_font() { return true; } let Some(font) = system_japanese_font() else { return false }; - let families = crate::theme::font_families() - .into_iter() - .map(|family| egui::epaint::text::InsertFontFamily { family, priority: egui::epaint::text::FontPriority::Lowest }) - .collect(); - // queued for the next pass (works before the first frame); a no-op when already installed - ctx.add_font(egui::epaint::text::FontInsert { name: JAPANESE_FONT.into(), data: (*font).clone(), families }); + let mut fonts = ctx.fonts(|f| f.definitions().clone()); + fonts.font_data.insert(JAPANESE_FONT.into(), font); + prioritize_system_japanese(&mut fonts); + // Apply on the next pass, including when a theme change reinstalled the Latin fonts. + ctx.set_fonts(fonts); true } @@ -244,6 +257,29 @@ pub fn install_japanese_font(ctx: &egui::Context) -> bool { mod tests { use super::*; + #[test] + fn system_japanese_fallback_precedes_chinese_in_all_ui_families() { + let mut fonts = egui::FontDefinitions::default(); + for family in crate::theme::font_families() { + let stack = fonts.families.entry(family).or_default(); + stack.insert(0, "app-latin".into()); + stack.push("craft:chinese fallback".into()); + stack.push("system-chinese".into()); + } + // Theme reinstalls and repeated language selections must not duplicate the font. + prioritize_system_japanese(&mut fonts); + prioritize_system_japanese(&mut fonts); + for family in crate::theme::font_families() { + let stack = &fonts.families[&family]; + assert_eq!(stack.first().map(String::as_str), Some("app-latin"), "{family:?}"); + let japanese = stack.iter().position(|name| name == JAPANESE_FONT).unwrap(); + let craft_chinese = stack.iter().position(|name| name == "craft:chinese fallback").unwrap(); + let system_chinese = stack.iter().position(|name| name == "system-chinese").unwrap(); + assert!(japanese > 0 && japanese < craft_chinese && japanese < system_chinese, "{family:?}: {stack:?}"); + assert_eq!(stack.iter().filter(|name| name.as_str() == JAPANESE_FONT).count(), 1); + } + } + #[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"))] { From d67583385b3807cb23f4a66cb5f7512bb632da9f Mon Sep 17 00:00:00 2001 From: ReReddo <249398616+ReReddo@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:14:13 +0300 Subject: [PATCH 004/144] project panel: drag in empty space to marquee-select, in List, Icon and Freeform views (#578) Dragging with the left button from empty space in the Project panel draws a selection rectangle, and every clip whose row or card it touches is selected, live. In Icon view the gaps between cards count as empty space too, so a click there deselects. A plain drag replaces the selection, Shift or Ctrl adds to it, and Escape cancels and restores the selection the drag began with. The selection goes through project.select; the rectangle registers project.marquee. Synthetic input (control channel, MCP, UI tests) now sends ModifiersChanged around a pointer press and release that carry modifiers, so a drag that egui recognises frames after the press still sees Shift / Ctrl, as with real keys. This also lets automated Shift-drags of the Timeline marquee add to the selection. Limits: the rectangle does not select bins (the panel tracks one selected bin), and in List view, where rows span the full width, it starts in the empty space below the rows. Tests: five project_panel_ui tests. The ui-egui suite passes except the two Windows CRLF failures fixed in #488. --- crates/ui-egui/src/lib.rs | 16 ++- crates/ui-egui/src/panels/project_views.rs | 92 +++++++++++++-- crates/ui-egui/tests/project_panel_ui.rs | 126 +++++++++++++++++++++ 3 files changed, 224 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c03c4ecc..9fbb8d59 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1609,7 +1609,21 @@ impl eframe::App for FilmcraftApp { .position(|e| pointer(e) || matches!(e, egui::Event::Key { pressed: false, .. })) .map_or(self.synthetic.len(), |i| if pointer(&self.synthetic[i]) { i.max(1) } else { i + 1 }) }; - raw_input.events.extend(self.synthetic.drain(..n)); + // A press's modifiers go down before it and up after its release, as real keys would, + // so a drag that egui recognises frames after the press still sees Shift / Ctrl. + for e in self.synthetic.drain(..n) { + match e { + egui::Event::PointerButton { pressed: true, modifiers, .. } if !modifiers.is_none() => { + raw_input.events.push(egui::Event::ModifiersChanged(modifiers)); + raw_input.events.push(e); + } + egui::Event::PointerButton { pressed: false, modifiers, .. } if !modifiers.is_none() => { + raw_input.events.push(e); + raw_input.events.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + } + _ => raw_input.events.push(e), + } + } } } diff --git a/crates/ui-egui/src/panels/project_views.rs b/crates/ui-egui/src/panels/project_views.rs index 7a54afae..ce19774a 100644 --- a/crates/ui-egui/src/panels/project_views.rs +++ b/crates/ui-egui/src/panels/project_views.rs @@ -422,6 +422,7 @@ pub fn list_view(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: &View let root = app.session.project.root.find_bin(v.bin).cloned().unwrap_or_default(); let out = egui::ScrollArea::both().id_salt((&v.prefix, "list-scroll")).auto_shrink([false, false]).show(&mut bui, |ui| { ui.set_min_width(lc.width); + let bg = background(ui, v); let mut row = 0usize; list_bin(app, ui, &root, 0, filter, &mut row, actions, v, &lc); if v.inst == Inst::Main && v.bin == app.session.project.root.id { @@ -430,7 +431,7 @@ pub fn list_view(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: &View }; crate::panels::menu_dialogs::search_bin_rows(app, ui, &mut row, actions, &mut draw); } - empty_space(app, ui, v, actions); + empty_space(app, ui, v, &bg, actions); }); // pinned header, scrolled horizontally with the rows let hr = Rect::from_min_size(rect.min, vec2(rect.width(), header_h)); @@ -491,21 +492,88 @@ pub fn list_view(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: &View } } -/// Empty space under the rows / cards: click deselects, double-click imports, right-click menu. -fn empty_space(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &View, actions: &mut Actions) { +/// The List / Icon view background, under the rows and cards (call it before drawing them, so they +/// sit on top). Starts the frame's [`note_hit`] list. +fn background(ui: &egui::Ui, v: &View) -> egui::Response { + ui.data_mut(|d| d.remove::>(hits_id(v))); + ui.interact(ui.clip_rect(), egui::Id::new((&v.prefix, "background")), Sense::click_and_drag()) +} + +/// Empty space under the rows / cards, and the gaps between them (`bg`): click deselects, +/// double-click imports, right-click menu, drag selects with a marquee. +fn empty_space(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &View, bg: &egui::Response, actions: &mut Actions) { let rest = ui.available_rect_before_wrap(); let r = Rect::from_min_size(rest.min, vec2(rest.width(), rest.height().max(40.0))); - let resp = ui.allocate_rect(r, Sense::click()); + ui.allocate_rect(r, Sense::hover()); app.auto.add(&format!("{}.empty", v.prefix), r.intersect(ui.clip_rect()), "Empty area"); - if resp.clicked() { + marquee(app, ui, v, bg, ui.max_rect().min, actions); + if bg.clicked() { app.ui.project_panel.selected_bin = None; actions.push(("project.select".into(), json!({"items": []}))); } - if resp.double_clicked() { + if bg.double_clicked() { actions.push(("file.import".into(), json!({}))); } let bin = (v.bin != app.session.project.root.id).then_some(v.bin.0); - resp.context_menu(|ui| background_menu(app, ui, v, bin, actions)); + bg.context_menu(|ui| background_menu(app, ui, v, bin, actions)); +} + +fn hits_id(v: &View) -> egui::Id { + egui::Id::new((&v.prefix, "marquee-hits")) +} + +/// Remember where an item's row or card was drawn this frame, for [`marquee`]. +fn note_hit(ui: &egui::Ui, v: &View, id: ItemId, r: Rect) { + ui.data_mut(|d| d.get_temp_mut_or_default::>(hits_id(v)).push((id.0, r))); +} + +/// Marquee selection: dragging `bg` (empty space) selects every item drawn this frame whose rect +/// the rectangle touches, live. Shift/Ctrl add to the selection the drag started with; Escape +/// cancels and restores it. `origin` is the content's top-left, so scrolling keeps the anchor. +fn marquee(app: &mut FilmcraftApp, ui: &egui::Ui, v: &View, bg: &egui::Response, origin: egui::Pos2, actions: &mut Actions) { + // (press point relative to `origin`, selection when the drag began, adds to it) + type Drag = (egui::Vec2, Vec, bool); + let key = egui::Id::new((&v.prefix, "marquee")); + let hits: Vec<(u64, Rect)> = ui.data_mut(|d| d.remove_temp(hits_id(v))).unwrap_or_default(); + let current: Vec = app.session.state.project_selection.iter().map(|i| i.0).collect(); + // the left button only: right-click opens the menu, the middle button is not a selection + let primary = egui::PointerButton::Primary; + if bg.drag_started_by(primary) { + let press = ui.input(|i| i.pointer.press_origin()).unwrap_or(origin); + let m = ui.input(|i| i.modifiers); + let add = m.shift || m.command; + if !add { + app.ui.project_panel.selected_bin = None; + } + ui.data_mut(|d| d.insert_temp::(key, (press - origin, current.clone(), add))); + } + let Some((anchor, before, add)) = ui.data(|d| d.get_temp::(key)) else { return }; + let cancel = ui.input(|i| i.key_pressed(egui::Key::Escape)); + if cancel || !(bg.dragged_by(primary) || bg.drag_stopped_by(primary)) { + ui.data_mut(|d| d.remove::(key)); + if cancel && before != current { + actions.push(("project.select".into(), json!({"items": before}))); + } + return; + } + let Some(now) = ui.ctx().pointer_latest_pos() else { return }; + let r = Rect::from_two_pos(origin + anchor, now); + let mut sel = if add { before } else { Vec::new() }; + for (id, hr) in &hits { + if hr.intersects(r) && !sel.contains(id) { + sel.push(*id); + } + } + if sel != current { + actions.push(("project.select".into(), json!({"items": sel}))); + } + let t = app.tokens; + ui.painter().rect_filled(r, 0.0, t.accent.gamma_multiply(0.18)); + ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Inside); + app.auto.add(&format!("{}.marquee", v.prefix), r, "Marquee"); + if bg.drag_stopped_by(primary) { + ui.data_mut(|d| d.remove::(key)); + } } fn background_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &View, bin: Option, actions: &mut Actions) { @@ -580,6 +648,7 @@ fn list_bin(app: &mut FilmcraftApp, ui: &mut egui::Ui, bin: &Bin, depth: usize, } let Some(it) = app.session.project.item(id).cloned() else { continue }; let (r, resp) = ui.allocate_exact_size(vec2(lc.width, lc.row_h), Sense::click_and_drag()); + note_hit(ui, v, id, r); let selected = app.session.state.project_selection.contains(&id); if selected { ui.painter().rect_filled(r, 0.0, t.row_selected); @@ -806,6 +875,7 @@ pub fn icon_view(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: &View }; let n = bins.len() + ids.len(); egui::ScrollArea::vertical().id_salt((&v.prefix, "icon-scroll")).auto_shrink([false, false]).show(ui, |ui| { + let bg = background(ui, v); let per_row = ((rect.width() - 12.0) / cell.x).floor().max(1.0) as usize; if v.inst == Inst::Main { app.ui.keys.icon_columns = per_row; @@ -827,13 +897,14 @@ pub fn icon_view(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: &View let Some(kind) = app.session.project.item(id).map(|x| x.kind.clone()) else { continue }; let was_selected = app.session.state.project_selection.contains(&id); if let Some(resp) = card(app, ui, r, id, &v.prefix, true, true, Sense::click_and_drag()) { + note_hit(ui, v, id, r); let name_r = Rect::from_min_max(pos2(r.min.x, r.min.y + th_h), r.max); slow_click_rename(app, ui, &resp, name_r, id, was_selected); item_interactions(app, ui, &resp, id, &kind, actions, true, true); } } } - empty_space(app, ui, v, actions); + empty_space(app, ui, v, &bg, actions); }); } @@ -856,7 +927,8 @@ pub fn freeform_view(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: & }; let extent = cards.iter().fold(vec2(rect.width(), rect.height()), |e, c| vec2(e.x.max(c.x + c.size + 40.0), e.y.max(c.y + c.size * 9.0 / 16.0 + 60.0))); egui::ScrollArea::both().id_salt((&v.prefix, "ff-scroll")).auto_shrink([false, false]).show(ui, |ui| { - let (canvas, bg) = ui.allocate_exact_size(extent, Sense::click()); + ui.data_mut(|d| d.remove::>(hits_id(v))); + let (canvas, bg) = ui.allocate_exact_size(extent, Sense::click_and_drag()); app.auto.add(&format!("{}.freeform", v.prefix), canvas, "Freeform canvas"); if opts.snap { let g = opts.grid.max(4.0); @@ -888,6 +960,7 @@ pub fn freeform_view(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: & let r = Rect::from_min_size(canvas.min + vec2(c.x, c.y) + off, vec2(c.size, c.size * 9.0 / 16.0 + 20.0)); let Some(kind) = app.session.project.item(ItemId(c.item)).map(|x| x.kind.clone()) else { continue }; let Some(resp) = card(app, ui, r, ItemId(c.item), &v.prefix, opts.show_names, opts.show_durations, Sense::click_and_drag()) else { continue }; + note_hit(ui, v, ItemId(c.item), r); if let Some(s) = c.stack && s == c.item && let Some(n) = stack_n.get(&s) @@ -914,6 +987,7 @@ pub fn freeform_view(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: & } resp.context_menu(|ui| card_menu(app, ui, v, c.item, actions)); } + marquee(app, ui, v, &bg, canvas.min, actions); if let Some((item, off)) = released { ui.ctx().data_mut(|d| d.remove::<(u64, egui::Vec2)>(drag_key)); let inside = ui.ctx().pointer_latest_pos().is_some_and(|p| rect.contains(p)); diff --git a/crates/ui-egui/tests/project_panel_ui.rs b/crates/ui-egui/tests/project_panel_ui.rs index bb504f32..cf381abd 100644 --- a/crates/ui-egui/tests/project_panel_ui.rs +++ b/crates/ui-egui/tests/project_panel_ui.rs @@ -626,3 +626,129 @@ fn media_browser_drag_imports_into_the_project_panel() { assert_eq!(d.app().session.project.items.len(), n0 + 1, "kept after a drop on the Timeline"); let _ = std::fs::remove_dir_all(&dir); } + +// ---- marquee selection (#578) + +/// The Footage bin open in place in `view`, with its items' rects (row by row, left to right). +fn footage_in(d: &mut Driver, view: &str) -> Vec<(u64, [f32; 4])> { + let (bin, items) = d.footage(); + d.exec("project.view.set", json!({"view": view})); + d.ok("ui.menu.invoke", json!({"id": "projectPanel.openBin", "params": {"bin": bin, "how": "inPlace"}})); + d.wait_for(&format!("project.item.{}", items[0])); + let mut rects: Vec<(u64, [f32; 4])> = items.iter().map(|i| (*i, d.rect(&format!("project.item.{i}")))).collect(); + rects.sort_by(|a, b| (a.1[1], a.1[0]).partial_cmp(&(b.1[1], b.1[0])).unwrap()); + rects +} + +fn selection(d: &mut Driver) -> Vec { + let mut s: Vec = d.app().session.state.project_selection.iter().map(|i| i.0).collect(); + s.sort_unstable(); + s +} + +fn sorted(mut v: Vec) -> Vec { + v.sort_unstable(); + v +} + +fn centre(r: [f32; 4]) -> (f32, f32) { + (r[0] + r[2] / 2.0, r[1] + r[3] / 2.0) +} + +fn marquee(d: &mut Driver, from: (f32, f32), to: (f32, f32), shift: bool) { + d.ok("ui.drag", json!({"from": {"x": from.0, "y": from.1}, "to": {"x": to.0, "y": to.1}, "steps": 12, "modifiers": {"shift": shift}})); + d.frames(3); +} + +#[test] +fn marquee_selects_cards_in_icon_view() { + let mut d = project_driver(); + let cards = footage_in(&mut d, "icon"); + let [(i0, a), (i1, b), (i2, c)] = [cards[0], cards[1], cards[2]]; + assert!((a[1] - c[1]).abs() < 1.0, "the first three cards share a row"); + // from the gap above-left of the first card to the middle of the second + marquee(&mut d, (a[0] - 4.0, a[1] - 4.0), centre(b), false); + assert_eq!(selection(&mut d), sorted(vec![i0, i1])); + // Shift adds + marquee(&mut d, (c[0] + 4.0, c[1] - 4.0), centre(c), true); + assert_eq!(selection(&mut d), sorted(vec![i0, i1, i2])); + // a plain drag replaces + marquee(&mut d, (a[0] - 4.0, a[1] - 4.0), centre(a), false); + assert_eq!(selection(&mut d), vec![i0]); + // the gaps between cards are empty space: a click there deselects + d.ok("ui.click", json!({"x": a[0] - 4.0, "y": a[1] - 4.0})); + d.frames(2); + assert!(selection(&mut d).is_empty()); +} + +#[test] +fn escape_cancels_a_marquee_and_keeps_the_selection() { + let mut d = project_driver(); + let cards = footage_in(&mut d, "icon"); + let [(i0, a), (i1, b), (i2, c)] = [cards[0], cards[1], cards[2]]; + d.exec("project.select", json!({"items": [i2]})); + let send = |d: &mut Driver, e: egui::Event| { + d.harness.input_mut().events.push(e); + d.frames(1); + }; + let (from, to) = (egui::pos2(a[0] - 4.0, a[1] - 4.0), egui::pos2(centre(b).0, centre(b).1)); + send(&mut d, egui::Event::PointerMoved(from)); + send(&mut d, egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + for k in 1..=10 { + send(&mut d, egui::Event::PointerMoved(from.lerp(to, k as f32 / 10.0))); + } + assert_eq!(selection(&mut d), sorted(vec![i0, i1]), "the selection follows the rectangle"); + send(&mut d, egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }); + let end = egui::pos2(centre(c).0, centre(c).1); + send(&mut d, egui::Event::PointerMoved(end)); + send(&mut d, egui::Event::PointerButton { pos: end, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + d.frames(2); + assert_eq!(selection(&mut d), vec![i2], "Escape restored the selection the drag began with"); +} + +#[test] +fn only_the_left_button_draws_a_marquee() { + let mut d = project_driver(); + let cards = footage_in(&mut d, "icon"); + let [(i0, a), (_, b)] = [cards[0], cards[1]]; + d.exec("project.select", json!({"items": [i0]})); + let (from, to) = (egui::pos2(a[0] - 4.0, a[1] - 4.0), egui::pos2(centre(b).0, centre(b).1)); + for button in [egui::PointerButton::Middle, egui::PointerButton::Secondary] { + let mut send = |e: egui::Event| { + d.harness.input_mut().events.push(e); + d.frames(1); + }; + send(egui::Event::PointerMoved(from)); + send(egui::Event::PointerButton { pos: from, button, pressed: true, modifiers: Default::default() }); + for k in 1..=10 { + send(egui::Event::PointerMoved(from.lerp(to, k as f32 / 10.0))); + } + send(egui::Event::PointerButton { pos: to, button, pressed: false, modifiers: Default::default() }); + d.key("Escape"); + assert_eq!(selection(&mut d), vec![i0], "{button:?} drag left the selection alone"); + assert!(!d.has("project.marquee")); + } +} + +#[test] +fn marquee_selects_rows_in_list_view() { + let mut d = project_driver(); + let rows = footage_in(&mut d, "list"); + let n = rows.len(); + let (last, before_last) = (rows[n - 1], rows[n - 2]); + let empty = d.rect("project.empty"); + // from the empty space under the rows up into the second-to-last row + marquee(&mut d, (empty[0] + 60.0, empty[1] + 20.0), (empty[0] + 60.0, centre(before_last.1).1), false); + assert_eq!(selection(&mut d), sorted(vec![last.0, before_last.0])); +} + +#[test] +fn marquee_selects_cards_in_freeform_view() { + let mut d = project_driver(); + let cards = footage_in(&mut d, "freeform"); + let [(i0, a), (i1, b)] = [cards[0], cards[1]]; + assert!((a[1] - b[1]).abs() < 1.0, "the first two cards share a row"); + marquee(&mut d, (a[0] - 6.0, a[1] - 6.0), centre(b), false); + assert_eq!(selection(&mut d), sorted(vec![i0, i1])); + assert!(!d.app().session.project.item(ItemId(i0)).unwrap().metadata.contains_key(FREEFORM_POS), "the marquee moved no card"); +} From 4f8dcdf5877ee7f786b60d7d28db530006f95514 Mon Sep 17 00:00:00 2001 From: liangforstudy <36067395+liangforstudy@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:40:58 +0800 Subject: [PATCH 005/144] #402: WebM transparency (VP9 alpha in BlockAdditions) A WebM VP9 track with AlphaMode 1 stores its alpha as a second, independently coded VP9 stream in each block's BlockAdditional (BlockAddID 1). The Matroska reader detected AlphaMode (has_alpha was true) but never read the additions, so transparent areas rendered black. - matroska: BlockGroups' BlockAdditions are parsed; the BlockAdditional with ID 1 of an unlaced block is kept as Sample::addition, read with MkvFile::read_sample_addition. - codecs: a WebM VP9 track with AlphaMode and additions gets an AlphaDecoder (webm_alpha.rs): it decodes each sample's alpha frame with a second software VP9 decoder and attaches its luma plane (full range, converted to the picture's depth) as the frame's alpha. After a seek the alpha decoder resynchronises from the nearest alpha key frame. Colour pictures are held until their alpha picture is out, since the two decoders may return pictures after different calls. A missing or undecodable alpha frame leaves that picture opaque; it never fails the colour decode. Tests: vp9_webm_alpha_layer_matches_libvpx (frames out of order and forward, colour and alpha exact against ffmpeg's libvpx decode; fails before: no alpha plane; passed 200 of 200 runs with four in parallel), set_alpha unit tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qq8BK1LmohzE1ga4NgAdWM --- crates/codecs/src/lib.rs | 1 + crates/codecs/src/mkv.rs | 21 ++- crates/codecs/src/tests.rs | 62 +++++++ crates/codecs/src/webm_alpha.rs | 292 ++++++++++++++++++++++++++++++++ crates/matroska/src/demux.rs | 57 ++++++- crates/matroska/src/ids.rs | 3 + crates/matroska/src/track.rs | 4 + 7 files changed, 435 insertions(+), 5 deletions(-) create mode 100644 crates/codecs/src/webm_alpha.rs diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index a31da3a7..f7de9e3d 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -30,6 +30,7 @@ pub mod mxf; pub mod ogg; mod stream_color; pub mod video; +mod webm_alpha; use std::sync::{Arc, RwLock}; diff --git a/crates/codecs/src/mkv.rs b/crates/codecs/src/mkv.rs index cef204d9..2efb5b53 100644 --- a/crates/codecs/src/mkv.rs +++ b/crates/codecs/src/mkv.rs @@ -58,6 +58,8 @@ pub struct MkvSource { /// Video codec as an ISO-BMFF sample entry (for the decoder factories). ventry: Option, video: GopCache, + /// The alpha layer of a VP9 track with `AlphaMode` 1 (WebM transparency, #402). + alpha: Option>, /// The playable audio tracks in file order: `info.audio_streams[k]` describes `audios[k]`. audios: Vec, } @@ -380,7 +382,12 @@ impl MkvSource { Some(MkvAudio { track: i, state, starts, preroll }) }) .collect(); - Ok(Self { info, bytes, file, vtrack, ventry, video: GopCache::new(explicit_color).with_rotation(rotation), audios }) + // WebM transparency: VP9 alpha in each block's BlockAdditional (ID 1) + let alpha = vtrack.and_then(|i| file.tracks.get(i)).filter(|t| { + matches!(t.codec, Codec::Vp9 { .. }) && t.video.as_ref().is_some_and(|v| v.alpha_mode != 0) && t.samples.iter().any(|s| s.addition.is_some()) + }); + let alpha = alpha.map(|t| Arc::new(crate::webm_alpha::AlphaLayer::new(bytes.clone(), t.samples.iter().map(|s| (s.pts, s.addition))))); + Ok(Self { info, bytes, file, vtrack, ventry, video: GopCache::new(explicit_color).with_rotation(rotation), alpha, audios }) } fn read(&self, track: usize, i: usize) -> crate::Result> { @@ -486,9 +493,15 @@ impl VideoSamples for MkvVideo<'_> { self.src.read(self.track, i) } fn make_decoder(&self) -> crate::Result> { - match &self.src.ventry { - Some(e) => make_video_decoder(e), - None => Err(CodecError::Unsupported(format!("no decoder for {} video", codec_label(&self.src.file.tracks[self.track].codec)))), + let Some(e) = &self.src.ventry else { + return Err(CodecError::Unsupported(format!("no decoder for {} video", codec_label(&self.src.file.tracks[self.track].codec)))); + }; + let color = make_video_decoder(e)?; + let Some(layer) = &self.src.alpha else { return Ok(color) }; + // the alpha layer always goes through our own decoder (it is luma only) + match crate::software_video_decoder(e) { + Ok(alpha) => Ok(Box::new(crate::webm_alpha::AlphaDecoder::new(color, alpha, layer.clone()))), + Err(_) => Ok(color), } } } diff --git a/crates/codecs/src/tests.rs b/crates/codecs/src/tests.rs index 3f1f5047..751b18fd 100644 --- a/crates/codecs/src/tests.rs +++ b/crates/codecs/src/tests.rs @@ -387,6 +387,68 @@ fn mkv_vp9_422_seeks_bit_exact() { check_vp9_seeks("noise_vp9_422.mkv", "Matroska", "yuv422p", &["-profile:v", "1", "-deadline", "realtime", "-speed", "8"], 30); } +/// #402: a WebM whose VP9 track carries an alpha layer (`AlphaMode` 1, a second VP9 stream in +/// each block's `BlockAdditional`) decodes with that alpha. Frames requested out of order (seeks +/// across key frames, then forward) match libvpx's decode exactly, colour and alpha. +#[test] +fn vp9_webm_alpha_layer_matches_libvpx() { + let (w, h, frames) = (176usize, 144usize, 30usize); + // the alpha changes every frame, so a picture paired with the wrong alpha frame shows + let Some(path) = fixture_path( + "alpha_vp9.webm", + &[ + "-f", + "lavfi", + "-i", + "testsrc2=s=176x144:r=25,format=yuva420p,geq=lum='lum(X,Y)':cb='cb(X,Y)':cr='cr(X,Y)':a='mod(X*2+N*7\\,256)'", + "-frames:v", + "30", + "-c:v", + "libvpx-vp9", + "-pix_fmt", + "yuva420p", + "-g", + "10", + "-deadline", + "realtime", + "-speed", + "8", + "-b:v", + "600k", + ], + ) else { + return; + }; + // the reference: ffmpeg's libvpx decoder (its native VP9 decoder ignores the alpha layer) + let src_path = path.to_string_lossy().into_owned(); + let Some(reference) = + fixture("alpha_vp9.webm.yuva420p.yuv", &["-c:v", "libvpx-vp9", "-i", &src_path, "-fps_mode", "passthrough", "-f", "rawvideo", "-pix_fmt", "yuva420p"]) + else { + return; + }; + let (luma, chroma) = (w * h, (w / 2) * (h / 2)); + let frame_len = luma * 2 + chroma * 2; + if reference.len() != frame_len * frames { + eprintln!("libvpx reference has {} bytes, not {}; skipping", reference.len(), frame_len * frames); + return; + } + let bytes: Arc<[u8]> = std::fs::read(&path).unwrap().into(); + let src = crate::open_bytes("alpha_vp9.webm", bytes).unwrap(); + let info = src.info().clone(); + assert_eq!(info.container, "WebM"); + assert!(info.video.as_ref().unwrap().has_alpha); + let rate = info.frame_rate(); + let order: Vec = [29, 3, 17, 12, 0, 25, 21, 9].into_iter().chain(10..frames).collect(); + for k in order { + let f = src.video_frame(FrameRequest::full(rate.tick_of(k as i64))).unwrap(); + let r = &reference[k * frame_len..(k + 1) * frame_len]; + let filmcraft_frame::PixelData::Yuv8 { planes, alpha, .. } = &f.data else { panic!("frame {k}: not 8-bit YUV: {}", f.format_label()) }; + assert_eq!(planes[0].as_slice(), &r[..luma], "frame {k}: luma"); + let alpha = alpha.as_ref().unwrap_or_else(|| panic!("frame {k}: no alpha plane")); + assert_eq!(alpha.as_slice(), &r[luma + 2 * chroma..], "frame {k}: alpha"); + } +} + /// Every sample flagged as sync (as in an MP4 without `stss`): the GOP cache must still start /// decoding at a real VP9 key frame. #[test] diff --git a/crates/codecs/src/webm_alpha.rs b/crates/codecs/src/webm_alpha.rs new file mode 100644 index 00000000..e602486e --- /dev/null +++ b/crates/codecs/src/webm_alpha.rs @@ -0,0 +1,292 @@ +//! WebM alpha (#402): a VP9 track with `AlphaMode` 1 carries, in each block's `BlockAdditional` +//! with `BlockAddID` 1, a second VP9 stream coded independently of the colour one. Its luma plane, +//! full range, is the frame's alpha (the WebM convention, as libvpx-based decoders read it). +//! +//! [`AlphaDecoder`] wraps the colour decoder: every sample it decodes, it also decodes that +//! sample's alpha frame with a second (software) VP9 decoder and attaches the luma plane to the +//! colour picture with the same timestamp, holding a colour picture back until its alpha is out. The alpha stream has key frames of its own, which need +//! not coincide with the colour ones: after a seek (or any other break in the order samples are +//! fed), the alpha decoder restarts from the nearest alpha key frame at or before the sample. +//! +//! Alpha is best effort: a missing, damaged or undecodable alpha frame leaves that picture opaque, +//! never fails the colour decode. + +use std::collections::{HashMap, VecDeque}; +use std::sync::Arc; + +use filmcraft_frame::{PixelData, VideoFrame}; + +use crate::video::{DecodedFrame, VideoDecoder}; + +/// How far back the alpha decoder looks for a key frame when it resynchronises. +const MAX_RESYNC: usize = 600; +/// Decoded alpha pictures waiting for their colour picture, and colour pictures waiting for their +/// alpha (VP9 pictures come out in order, so a few are plenty; past that a picture goes out opaque +/// rather than stall). +const MAX_PENDING: usize = 8; + +/// Where each sample's alpha frame is stored. +pub(crate) struct AlphaLayer { + src: crate::Src, + /// Per sample, in file (decode) order. + additions: Vec>, + pts: Vec, + /// First sample with each pts. + by_pts: HashMap, +} + +impl AlphaLayer { + /// `samples`: (pts, addition) of every sample of the track, in file order. + pub(crate) fn new(src: crate::Src, samples: impl Iterator)>) -> Self { + let (mut additions, mut all_pts) = (Vec::new(), Vec::new()); + let mut by_pts = HashMap::new(); + for (i, (pts, add)) in samples.enumerate() { + additions.push(add); + all_pts.push(pts); + by_pts.entry(pts).or_insert(i); + } + Self { src, additions, pts: all_pts, by_pts } + } + + fn read(&self, i: usize) -> Option> { + let (offset, size) = (*self.additions.get(i)?)?; + let end = offset.checked_add(size as u64)?; + if end > self.src.0.len() { + return None; + } + let mut data = vec![0; size as usize]; + self.src.0.read_at(offset, &mut data).ok()?; + Some(data) + } +} + +/// A colour decoder plus the decoder of its alpha layer. +/// +/// Both decoders may hand pictures back later than the sample that made them (frame threading), +/// and not necessarily after the same call. A colour picture is therefore held until its alpha +/// picture has come out, or until it is certain that it won't: the alpha decoder has moved past +/// it (pictures come out in order), it failed, or the sample has no alpha frame. +pub(crate) struct AlphaDecoder { + color: Box, + alpha: Box, + layer: Arc, + /// Sample the alpha decoder expects next; `None` after a reset or a failure. + next: Option, + /// Decoded alpha pictures not yet paired, by pts. + pending: VecDeque<(i64, VideoFrame)>, + /// Latest pts the alpha decoder has produced since the last reset. + alpha_max: Option, + /// Colour pictures waiting for their alpha, in output order. + held: VecDeque, +} + +impl AlphaDecoder { + pub(crate) fn new(color: Box, alpha: Box, layer: Arc) -> Self { + Self { color, alpha, layer, next: None, pending: VecDeque::new(), alpha_max: None, held: VecDeque::new() } + } + + fn keep(&mut self, frames: Vec) { + for f in frames { + self.alpha_max = Some(self.alpha_max.map_or(f.pts, |m| m.max(f.pts))); + self.pending.push_back((f.pts, f.frame)); + } + while self.pending.len() > MAX_PENDING { + self.pending.pop_front(); + } + } + + /// Decode the alpha frame of the sample with `pts`, first catching up from an alpha key frame + /// when the sample doesn't follow the previous one. + fn feed_alpha(&mut self, pts: i64) { + let Some(&i) = self.layer.by_pts.get(&pts) else { + self.next = None; + return; + }; + if self.next != Some(i) { + self.alpha.reset(); + self.pending.clear(); + self.alpha_max = None; + let is_key = |k: usize| self.layer.read(k).is_some_and(|d| filmcraft_vp9::is_keyframe(&d)); + let start = (i.saturating_sub(MAX_RESYNC)..=i).rev().find(|&k| is_key(k)); + if let Some(start) = start { + for k in start..i { + let (Some(d), Some(&p)) = (self.layer.read(k), self.layer.pts.get(k)) else { continue }; + match self.alpha.decode(&d, p) { + Ok(frames) => self.keep(frames), + Err(_) => break, + } + } + } + } + self.next = Some(i + 1); + let Some(d) = self.layer.read(i) else { return }; + match self.alpha.decode(&d, pts) { + Ok(frames) => self.keep(frames), + Err(e) => { + log::debug!("WebM alpha frame at pts {pts}: {e}"); + self.next = None; + } + } + } + + /// Whether the alpha picture for `pts` can still come out of the alpha decoder. + fn alpha_may_come(&self, pts: i64) -> bool { + let has_frame = self.layer.by_pts.get(&pts).is_some_and(|&i| self.layer.additions.get(i).is_some_and(|a| a.is_some())); + has_frame && self.next.is_some() && self.alpha_max.is_none_or(|m| m < pts) + } + + /// Release held colour pictures, in order, as far as their alpha is settled. `all`: release + /// every one (flush), with or without alpha. + fn release(&mut self, all: bool) -> Vec { + let mut out = Vec::new(); + while let Some(f) = self.held.front() { + let pts = f.pts; + let found = self.pending.iter().position(|(p, _)| *p == pts); + if found.is_none() && !all && self.held.len() <= MAX_PENDING && self.alpha_may_come(pts) { + break; + } + let Some(mut f) = self.held.pop_front() else { break }; + if let Some(k) = found + && let Some((_, a)) = self.pending.remove(k) + { + set_alpha(&mut f.frame, &a); + } + out.push(f); + } + out + } +} + +impl VideoDecoder for AlphaDecoder { + fn decode(&mut self, sample: &[u8], pts: i64) -> crate::Result> { + let out = self.color.decode(sample, pts)?; + self.held.extend(out); + self.feed_alpha(pts); + Ok(self.release(false)) + } + fn flush(&mut self) -> Vec { + let out = self.color.flush(); + self.held.extend(out); + let rest = self.alpha.flush(); + self.keep(rest); + self.release(true) + } + fn reset(&mut self) { + self.color.reset(); + self.alpha.reset(); + self.next = None; + self.pending.clear(); + self.alpha_max = None; + self.held.clear(); + } + fn name(&self) -> &str { + self.color.name() + } + fn intra_only(&self) -> bool { + self.color.intra_only() + } + fn is_random_access(&self, sample: &[u8]) -> Option { + self.color.is_random_access(sample) + } + fn is_disposable(&self, sample: &[u8]) -> bool { + self.color.is_disposable(sample) + } + fn set_draft(&mut self, on: bool) { + self.color.set_draft(on); + self.alpha.set_draft(on); + } + fn thread_limited(&self) -> bool { + self.color.thread_limited() + } +} + +/// The luma plane of `a` (an alpha picture) as `frame`'s alpha, converted to `frame`'s bit depth. +/// Nothing happens when the sizes differ or either picture has no usable planes. +pub(crate) fn set_alpha(frame: &mut VideoFrame, a: &VideoFrame) { + if (frame.width, frame.height) != (a.width, a.height) { + return; + } + let n = frame.width as usize * frame.height as usize; + // alpha codes as 16-bit with their depth, so every pairing converts through one path + let (codes, abits): (Vec, u32) = match &a.data { + PixelData::Yuv8 { planes, .. } => (planes[0].iter().map(|&v| v as u16).collect(), 8), + PixelData::Yuv16 { planes, bits, .. } => (planes[0].to_vec(), (*bits).clamp(1, 16)), + _ => return, + }; + if codes.len() != n { + return; + } + let rescale = |v: u16, to: u32| -> u32 { + let (from_max, to_max) = ((1u32 << abits) - 1, (1u32 << to.clamp(1, 16)) - 1); + (v as u32 * to_max + from_max / 2) / from_max + }; + match &mut frame.data { + PixelData::Yuv8 { alpha, .. } => *alpha = Some(Arc::new(codes.iter().map(|&v| rescale(v, 8) as u8).collect())), + PixelData::Yuv16 { alpha, bits, .. } => { + let to = *bits; + *alpha = Some(Arc::new(codes.iter().map(|&v| rescale(v, to) as u16).collect())); + } + PixelData::Rgba8(rgba) => { + let px = Arc::make_mut(rgba); + for (p, &v) in px.chunks_exact_mut(4).zip(&codes) { + if let Some(slot) = p.get_mut(3) { + *slot = rescale(v, 8) as u8; + } + } + } + PixelData::RgbaF32(_) => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + use filmcraft_frame::Chroma; + + fn yuv8(w: u32, h: u32, luma: Vec) -> VideoFrame { + let n = (w * h) as usize; + let c = n / 4; + let data = PixelData::Yuv8 { planes: [Arc::new(luma), Arc::new(vec![128; c]), Arc::new(vec![128; c])], chroma: Chroma::C420, alpha: None }; + VideoFrame { width: w, height: h, data, color: Default::default(), par: (1, 1), pts: filmcraft_time::Tick::ZERO } + } + + fn yuv16(w: u32, h: u32, bits: u32) -> VideoFrame { + let n = (w * h) as usize; + let data = + PixelData::Yuv16 { planes: [Arc::new(vec![0; n]), Arc::new(vec![0; n / 4]), Arc::new(vec![0; n / 4])], chroma: Chroma::C420, bits, alpha: None }; + VideoFrame { width: w, height: h, data, color: Default::default(), par: (1, 1), pts: filmcraft_time::Tick::ZERO } + } + + #[test] + fn alpha_luma_becomes_the_alpha_plane_at_the_frame_depth() { + let a = yuv8(2, 2, vec![0, 128, 255, 64]); + // 8-bit picture: the luma as is + let mut f = yuv8(2, 2, vec![9; 4]); + set_alpha(&mut f, &a); + let PixelData::Yuv8 { alpha, .. } = &f.data else { panic!() }; + assert_eq!(alpha.as_deref(), Some(&vec![0, 128, 255, 64])); + // 10-bit picture: full range kept (255 → 1023) + let mut f = yuv16(2, 2, 10); + set_alpha(&mut f, &a); + let PixelData::Yuv16 { alpha, .. } = &f.data else { panic!() }; + assert_eq!(alpha.as_deref(), Some(&vec![0, 514, 1023, 257])); + // RGBA picture (VP9 RGB profiles): the A channel + let mut f = VideoFrame::rgba8(2, 2, vec![7; 16]); + set_alpha(&mut f, &a); + let PixelData::Rgba8(px) = &f.data else { panic!() }; + assert_eq!(px.chunks(4).map(|p| p[3]).collect::>(), [0, 128, 255, 64]); + } + + #[test] + fn an_alpha_picture_of_another_size_is_ignored() { + let mut f = yuv8(4, 2, vec![9; 8]); + set_alpha(&mut f, &yuv8(2, 2, vec![0; 4])); + let PixelData::Yuv8 { alpha, .. } = &f.data else { panic!() }; + assert!(alpha.is_none()); + // a luma plane shorter than the picture (damaged) is ignored too + let mut f = yuv8(2, 2, vec![9; 4]); + set_alpha(&mut f, &yuv8(2, 2, vec![0; 3])); + let PixelData::Yuv8 { alpha, .. } = &f.data else { panic!() }; + assert!(alpha.is_none()); + } +} diff --git a/crates/matroska/src/demux.rs b/crates/matroska/src/demux.rs index 9344059c..4278a1b8 100644 --- a/crates/matroska/src/demux.rs +++ b/crates/matroska/src/demux.rs @@ -301,6 +301,9 @@ struct BlockRef { 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)] @@ -318,6 +321,7 @@ struct FrameRef { discard_padding: Option, block_offset: u64, lace: u16, + addition: Option<(u64, u32)>, } fn track_lookup(tracks: &[Track]) -> impl Fn(u64) -> Option + '_ { @@ -354,6 +358,36 @@ fn parse_block( 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>, @@ -376,17 +410,19 @@ fn parse_block_elem( 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) = (None, false, 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)?)), @@ -395,6 +431,8 @@ fn parse_block_elem( 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, @@ -405,6 +443,7 @@ fn parse_block_elem( discardable: false, invisible: flags & 0x08 != 0, discard_padding: pad, + addition, })) } _ => Ok(None), @@ -450,6 +489,7 @@ fn expand(b: &BlockRef, t: &Track, scale: u64, skip: u16, out: &mut impl Extend< discard_padding: b.discard_padding, block_offset: b.block_offset, lace: i as u16, + addition: b.addition, } })); } @@ -780,6 +820,7 @@ fn add_blocks(file: &mut MkvFile, cluster: u32, blocks: &[BlockRef]) { cluster, block_offset: f.block_offset, lace: f.lace, + addition: f.addition, }); } } @@ -838,6 +879,20 @@ impl MkvFile { 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)?; diff --git a/crates/matroska/src/ids.rs b/crates/matroska/src/ids.rs index 6334f451..3b7e025c 100644 --- a/crates/matroska/src/ids.rs +++ b/crates/matroska/src/ids.rs @@ -55,6 +55,9 @@ 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; diff --git a/crates/matroska/src/track.rs b/crates/matroska/src/track.rs index af8af356..1288895f 100644 --- a/crates/matroska/src/track.rs +++ b/crates/matroska/src/track.rs @@ -191,6 +191,10 @@ pub struct Sample { 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. From 628d573f1de3a3c4bc0c4429a879ca8a572e1e1f Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:29:34 +0200 Subject: [PATCH 006/144] Keyboard slip: clamp linked clips by one shared delta (#529) --- crates/engine/src/clip_ops_tests.rs | 27 +++++++++++++++++++++++++ crates/engine/src/commands.rs | 31 +++++++++++++++++++---------- crates/engine/src/keyboard.rs | 23 +++++++++++++-------- 3 files changed, 62 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/clip_ops_tests.rs b/crates/engine/src/clip_ops_tests.rs index b6fcfe86..ae73e578 100644 --- a/crates/engine/src/clip_ops_tests.rs +++ b/crates/engine/src/clip_ops_tests.rs @@ -923,3 +923,30 @@ fn linked_slip_keeps_picture_and_sound_aligned_at_unequal_media_limits() { s.execute("timeline.slip", json!({"clip": v, "deltaFrames": -10})).unwrap(); assert_eq!((clip(&s, v).source_in, clip(&s, a).source_in), (Tick(fr.0 * 10), Tick(fr.0 * 10))); } + +#[test] +fn keyboard_slip_keeps_linked_clips_in_sync_past_the_shorter_handle() { + let mut s = Session::default(); + s.execute("file.newProject", json!({"name": "Slip"})).unwrap(); + s.execute("file.newSequence", json!({"name": "Sequence", "fps": 25, "video": 1, "audio": 1, "width": 16, "height": 16})).unwrap(); + let r = s.execute("file.newOfflineFile", json!({"name": "Media", "seconds": 4, "fps": 25, "video": true, "audio": true})).unwrap(); + let item = r["item"].as_u64().unwrap(); + let fr = s.sequence_rate().tick_of(1); + let r = + s.execute("timeline.place", json!({"item": item, "track": "V1", "audioTrack": "A1", "time": 0, "sourceIn": fr.0 * 20, "duration": fr.0 * 60})).unwrap(); + let (v, a) = (r["clips"][0].as_u64().unwrap(), r["clips"][1].as_u64().unwrap()); + // lengthen only the picture: it keeps 10 frames of tail handle, the sound keeps 20 + s.execute("sequence.linkedSelection", json!({"on": false})).unwrap(); + s.execute("timeline.trim", json!({"clip": v, "edge": "out", "deltaFrames": 10})).unwrap(); + s.execute("sequence.linkedSelection", json!({"on": true})).unwrap(); + s.execute("timeline.select", json!({"clips": [v, a]})).unwrap(); + for _ in 0..3 { + s.execute("timeline.slipLeft5", json!({})).unwrap(); + } + assert_eq!((clip(&s, v).source_in, clip(&s, a).source_in), (Tick(fr.0 * 30), Tick(fr.0 * 30))); + // with linked selection off each selected clip still slips on its own + s.execute("sequence.linkedSelection", json!({"on": false})).unwrap(); + s.execute("timeline.select", json!({"clips": [v, a]})).unwrap(); + s.execute("timeline.slipLeft5", json!({})).unwrap(); + assert_eq!((clip(&s, v).source_in, clip(&s, a).source_in), (Tick(fr.0 * 30), Tick(fr.0 * 35))); +} diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 70cdeb65..e6efa169 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -340,6 +340,24 @@ fn move_clips(s: &mut Session, p: &Value) -> Result { Ok(json!({"moved": moves.iter().map(|m| m.0.0).collect::>(), "overwritten": overwritten})) } +/// Slip clips together: the media delta of each clip is scaled down by the same factor so that the +/// most constrained clip (media end or start) still moves, and no linked partner slips further. +pub(crate) fn slip_together(q: &mut filmcraft_project::Sequence, ctx: &mut edit::EditCtx<'_>, deltas: &[(ClipId, Tick)]) -> Result<()> { + let mut scale = 1.0_f64; + for (c, d) in deltas { + if d.0 == 0 { + continue; + } + let x = edit::slip(&mut q.clone(), *c, *d, ctx)?; + scale = scale.min(x.0.abs() as f64 / d.0.abs() as f64); + } + for (c, d) in deltas { + let dd = if scale < 1.0 { Tick((d.0 as f64 * scale).round() as i64) } else { *d }; + edit::slip(q, *c, dd, ctx)?; + } + Ok(()) +} + /// Expand a clip selection with linked partners (when linked selection is on). pub fn with_links(s: &Session, clips: &[ClipId]) -> Vec { let Some(seq) = s.active_sequence() else { return clips.to_vec() }; @@ -2220,17 +2238,8 @@ fn build() -> Vec { let clips = with_links(s, &[c]); s.edit_sequence("Slip", |q, ctx, _| { // clamp across all linked partners, then slip them by the common delta - let mut dd = d; - for c in &clips { - let x = edit::slip(&mut q.clone(), *c, dd, ctx)?; - if x.abs() < dd.abs() { - dd = x; - } - } - for c in &clips { - edit::slip(q, *c, dd, ctx)?; - } - Ok(()) + let deltas: Vec<(ClipId, Tick)> = clips.iter().map(|c| (*c, d)).collect(); + slip_together(q, ctx, &deltas) })?; Ok(Value::Null) }), diff --git a/crates/engine/src/keyboard.rs b/crates/engine/src/keyboard.rs index d8487251..ad5f5d2a 100644 --- a/crates/engine/src/keyboard.rs +++ b/crates/engine/src/keyboard.rs @@ -40,7 +40,7 @@ use filmcraft_project::{ClipId, ItemKind, ParamValue, Sequence, TrackId, TrackKi use filmcraft_time::Tick; use serde_json::{Value, json}; -use crate::commands::{CommandSpec, always, bad, has_selection, has_seq, item_p, time_p, with_links}; +use crate::commands::{CommandSpec, always, bad, has_selection, has_seq, item_p, slip_together, time_p, with_links}; use crate::{EngineError, Result, Session}; type Run = fn(&mut Session, &Value) -> Result; @@ -248,14 +248,21 @@ fn slip_selection(s: &mut Session, frames: i64) -> Result { let rate = s.sequence_rate(); let clips = with_links(s, &s.state.selection.clone()); let seq = s.active_sequence().ok_or(EngineError::NoSequence)?; - // media delta per clip (source frames run at the clip's speed) - let deltas: Vec<(ClipId, Tick)> = clips - .iter() - .filter_map(|c| seq.find_item(*c).map(|(_, i)| (*c, Tick((rate.tick_of(frames).0 as f64 * i.speed.abs().max(1e-9)).round() as i64)))) - .collect(); + let linked = s.state.linked_selection; + // media delta per clip (source frames run at the clip's speed); linked partners slip as one group + let mut groups: Vec<(Option, Vec<(ClipId, Tick)>)> = Vec::new(); + for c in &clips { + let Some((_, i)) = seq.find_item(*c) else { continue }; + let d = Tick((rate.tick_of(frames).0 as f64 * i.speed.abs().max(1e-9)).round() as i64); + let link = i.link.filter(|_| linked); + match groups.iter_mut().find(|(l, _)| link.is_some() && *l == link) { + Some((_, g)) => g.push((*c, d)), + None => groups.push((link, vec![(*c, d)])), + } + } s.edit_sequence("Slip", |q, ctx, _| { - for (c, d) in &deltas { - edit::slip(q, *c, *d, ctx)?; + for (_, g) in &groups { + slip_together(q, ctx, g)?; } Ok(()) })?; From dcf2d7f423febcefce07d5f1fe125429d0c66240 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:42:11 +0200 Subject: [PATCH 007/144] Reject unknown mcp options instead of silently ignoring them (#476) --- apps/filmcraft-cli/src/args.rs | 13 +++++++++++++ apps/filmcraft-cli/src/main.rs | 3 +++ docs/agents.md | 4 ++++ 3 files changed, 20 insertions(+) diff --git a/apps/filmcraft-cli/src/args.rs b/apps/filmcraft-cli/src/args.rs index bac68feb..3a61efbc 100644 --- a/apps/filmcraft-cli/src/args.rs +++ b/apps/filmcraft-cli/src/args.rs @@ -60,6 +60,11 @@ impl Args { self.opts.iter().any(|(n, _)| n == k) } + /// The first option that is not in `allowed`, if any. + pub fn unknown_opt(&self, allowed: &[&str]) -> Option<&str> { + self.opts.iter().map(|(n, _)| n.as_str()).find(|n| !allowed.contains(n)) + } + /// Command params from positionals `from..`: one JSON object, or `key=value` pairs /// (dotted keys nest: `color.r=1`). pub fn params_from(&self, from: usize) -> Result { @@ -135,6 +140,14 @@ mod tests { assert_eq!(a.params_from(2).unwrap(), json!({"a": {"b": [1]}})); } + #[test] + fn unknown_opt_reports_first_unlisted_option() { + let allowed = ["--bridge", "--project"]; + assert_eq!(args("mcp --automation-read-root X").unknown_opt(&allowed), Some("--automation-read-root")); + assert_eq!(args("mcp --bridge=a --typo=1").unknown_opt(&allowed), Some("--typo")); + assert_eq!(args("mcp --project p --bridge a").unknown_opt(&allowed), None); + } + #[test] fn double_dash_ends_options() { let a = args("import -- --weird-name.mov a.mov"); diff --git a/apps/filmcraft-cli/src/main.rs b/apps/filmcraft-cli/src/main.rs index be104b30..657ee4ff 100644 --- a/apps/filmcraft-cli/src/main.rs +++ b/apps/filmcraft-cli/src/main.rs @@ -427,6 +427,9 @@ async fn cli() { diag(format_args!("rendered {}x{} in {:.1} ms → {out}", img.w, img.h, dt.as_secs_f64() * 1000.0)); } "mcp" => { + if let Some(opt) = a.unknown_opt(&["--bridge", "--project", "--demo", "--data-dir"]) { + usage(format!("unknown mcp option `{opt}`")); + } let server = match a.opt("--bridge") { Some(addr) => filmcraft_automation::FilmcraftMcp::bridge(addr).unwrap_or_else(|e| fail(e)), None => match Backend::open(&a) { diff --git a/docs/agents.md b/docs/agents.md index 39d53f77..65bc1fac 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -14,6 +14,10 @@ FilmCraft from an agent. Part 2 covers developing FilmCraft as an agent. | Headless | `filmcraft-cli mcp --demo` or `--project p.fcproj` (neither = empty project) | an in-process engine session, no window | | Bridge | `filmcraft-cli mcp --bridge 127.0.0.1:9876` | the running desktop app started with `filmcraft --control 9876` | +Headless mode also takes `--data-dir DIR` (user presets). Any other option is rejected (exit 2): +there are no folder-confinement flags such as `--automation-read-root`, so the server can read +and write wherever the user can. + **Claude Code.** The repository's `.mcp.json` registers both servers (`filmcraft` = bridge, `filmcraft-headless` = demo). Both point at `target/release/filmcraft-cli`, so build it first: From 77becde085cdd28eb293dd1e3d8b035b0208c7c0 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:42:48 +0200 Subject: [PATCH 008/144] Audio Clip Mixer: click the dB readout to type a volume (#481) --- crates/ui-egui/src/panels/mixer.rs | 19 +++++++++++++++++-- crates/ui-egui/tests/mixer_ui.rs | 21 +++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/mixer.rs b/crates/ui-egui/src/panels/mixer.rs index db2c92d7..bcc1b42a 100644 --- a/crates/ui-egui/src/panels/mixer.rs +++ b/crates/ui-egui/src/panels/mixer.rs @@ -1010,8 +1010,23 @@ pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let m = meters.get(&tr.id.0).cloned().unwrap_or_default(); draw_meters(ui, mrect, &m, if m.is_empty() { filmcraft_render::mixer::width_of(tr.channels) } else { m.len() }, &t); } - small_text(ui, pos2(sr.min.x + 8.0, sr.max.y - 34.0), Align2::LEFT_CENTER, &db_text(vol), 11.5, dimc(t.hot_text)); - app.auto.add(&format!("{ap}.value"), Rect::from_min_size(pos2(sr.min.x + 4.0, sr.max.y - 42.0), vec2(50.0, 16.0)), &db_text(vol)); + // dB readout: drag to scrub, click to type a value (clamped to the fader range), like the track mixer + let vr = Rect::from_min_size(pos2(sr.min.x + 4.0, sr.max.y - 42.0), vec2(50.0, 16.0)); + let mut vui = ui.new_child(egui::UiBuilder::new().max_rect(vr)); + if !active { + vui.set_opacity(0.4); + } + let (vresp, nv) = crate::widgets::hot_number(&mut vui, egui::Id::new((&ap, "value")), vol.max(-96.0), 0.1, (FADER_MIN_DB, FADER_MAX_DB), 1, "", &t); + app.auto.add(&format!("{ap}.value"), vresp.rect, &db_text(vol)); + if let (Some(v), true) = (nv, active) { + set(&mut acts, "volume", "level", v, !vresp.dragged() || vresp.drag_started()); + if !vresp.dragged() { + release(&mut acts, "volume"); + } + } + if vresp.drag_stopped() { + release(&mut acts, "volume"); + } small_text(ui, pos2(sr.min.x + 6.0, sr.max.y - 10.0), Align2::LEFT_CENTER, &label, 12.0, t.text); small_text(ui, pos2(sr.min.x + 30.0, sr.max.y - 10.0), Align2::LEFT_CENTER, &tr.name, 12.0, t.text); } diff --git a/crates/ui-egui/tests/mixer_ui.rs b/crates/ui-egui/tests/mixer_ui.rs index 0d4b6f28..e698d2bf 100644 --- a/crates/ui-egui/tests/mixer_ui.rs +++ b/crates/ui-egui/tests/mixer_ui.rs @@ -269,6 +269,27 @@ fn clip_mixer_strips_follow_the_playhead() { d.snapshot("mixer-clip-mixer", Some("clipMixer.")); } +#[test] +fn clip_mixer_db_value_can_be_typed() { + let mut d = Driver::demo(); + d.ok("ui.panel.show", json!({"panel": "AudioClipMixer"})); + d.frames(3); + d.exec("playhead.set", json!({"seconds": 1.0})); + d.frames(2); + d.click("clipMixer.A1.value"); + d.ok("ui.key", json!({"key": "Cmd+A"})); + d.frames(1); + d.ok("ui.type", json!({"text": "-20"})); + d.frames(2); + d.ok("ui.key", json!({"key": "Enter"})); + d.frames(3); + let seq = d.exec("sequence.inspect", json!({})); + let fx = &seq["audio"][0]["items"][0]["effects"]; + let vol = fx.as_array().unwrap().iter().find(|e| e["effect"] == "volume").unwrap(); + let level: f64 = vol["params"]["level"]["value"].as_str().and_then(|v| v.strip_prefix("Float(")?.strip_suffix(')')?.parse().ok()).unwrap(); + assert!((level + 20.0).abs() < 1e-6, "typed -20 dB into the clip mixer readout: {level}"); +} + #[test] fn audio_gain_dialog_normalizes_the_selection() { let mut d = Driver::demo(); From 3a46156861dd92c8d1cfece427404407181b2e8d Mon Sep 17 00:00:00 2001 From: JordyUSA <75485565+JordyUSA@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:09:05 +0700 Subject: [PATCH 009/144] Export: CRF (constant quality) bitrate encoding for H.264 Adds BitrateMode::Crf with ExportSettings::crf (0-51, default 23) and maps it to the built-in encoder's RateControl::Crf, which export never used. The Export panel offers "CRF (constant quality)" with a Quality (CRF) slider in place of the bitrate fields; file.exportMedia takes `crf` and `bitrateMode: "crf"`. H.265 (hardware only) rejects it, and VideoToolbox / NVENC decline it so the built-in encoder runs. Settings saved before this read as CRF 23. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands.rs | 2 +- crates/engine/src/export_tests.rs | 14 +++++++++ crates/engine/src/export_tools.rs | 10 +++++-- crates/export/src/lib.rs | 22 +++++++++++++++ crates/export/src/settings.rs | 5 ++++ crates/export/src/settings_tests.rs | 36 ++++++++++++++++++++++++ crates/platform/src/hardware_encode.rs | 3 ++ crates/platform/src/nvenc/export.rs | 4 +++ crates/platform/tests/hardware_encode.rs | 1 + crates/ui-egui/src/i18n/es.tsv | 2 ++ crates/ui-egui/src/i18n/ja.tsv | 2 ++ crates/ui-egui/src/i18n/mod.rs | 2 +- crates/ui-egui/src/panels/export_mode.rs | 16 ++++++++--- 13 files changed, 111 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 70cdeb65..8cdeb61f 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1154,7 +1154,7 @@ fn build() -> Vec { "Media…", ["File", "Export"], None, - r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass"?,"hardwareEncoding":"off|auto"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"alpha":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#, + r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass|crf"?,"crf":0..51=23,"hardwareEncoding":"off|auto"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"alpha":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#, has_seq, crate::export_tools::export_media ), diff --git a/crates/engine/src/export_tests.rs b/crates/engine/src/export_tests.rs index 2757ebba..799e7608 100644 --- a/crates/engine/src/export_tests.rs +++ b/crates/engine/src/export_tests.rs @@ -481,6 +481,20 @@ fn hardware_encoding_is_off_unless_asked_for() { assert_eq!(old.hardware_encoding, Off); } +#[test] +fn crf_param_picks_constant_quality() { + use filmcraft_export::BitrateMode::{Cbr, Crf, Vbr1Pass}; + let s = demo(); + let setting = |p: Value| crate::export_tools::settings_from_params(&s, &p, "file.exportMedia").map(|(_, st)| (st.bitrate_mode, st.crf)); + assert_eq!(setting(json!({"path": "x.mp4"})).unwrap(), (Vbr1Pass, 23.0)); + // a factor alone means CRF mode; the mode can also be named, or another one kept explicitly + assert_eq!(setting(json!({"path": "x.mp4", "crf": 18})).unwrap(), (Crf, 18.0)); + assert_eq!(setting(json!({"path": "x.mp4", "bitrateMode": "crf"})).unwrap(), (Crf, 23.0)); + assert_eq!(setting(json!({"path": "x.mp4", "bitrateMode": "cbr", "crf": 18})).unwrap(), (Cbr, 18.0)); + let e = setting(json!({"path": "x.mp4", "bitrateMode": "abr"})).unwrap_err().to_string(); + assert!(e.contains("crf"), "{e}"); +} + #[test] fn queue_exports_several_sequences_and_ranges() { let mut s = demo(); diff --git a/crates/engine/src/export_tools.rs b/crates/engine/src/export_tools.rs index a2ef4ab4..e0511fab 100644 --- a/crates/engine/src/export_tools.rs +++ b/crates/engine/src/export_tools.rs @@ -26,7 +26,7 @@ use std::sync::Arc; use std::sync::atomic::Ordering; use filmcraft_export::presets::{DEFAULT_PRESET, preset_key}; -use filmcraft_export::{ExportPreset, ExportSettings, Format, HardwareEncoding}; +use filmcraft_export::{BitrateMode, ExportPreset, ExportSettings, Format, HardwareEncoding}; use filmcraft_project::{ItemId, Project}; use filmcraft_time::{FrameRate, Tick, TimeRange}; use serde::{Deserialize, Serialize}; @@ -213,7 +213,13 @@ pub fn settings_from_params(s: &Session, p: &Value, cmd: &str) -> Result<(Option settings.max_bitrate_kbps = Some(v); } if let Some(v) = str_p(p, "bitrateMode") { - settings.bitrate_mode = serde_json::from_value(json!(v)).map_err(|_| bad(cmd, "bitrateMode: cbr | vbr1Pass | vbr2Pass"))?; + settings.bitrate_mode = serde_json::from_value(json!(v)).map_err(|_| bad(cmd, "bitrateMode: cbr | vbr1Pass | vbr2Pass | crf"))?; + } + if let Some(v) = f64_p(p, "crf") { + settings.crf = v as f32; + if p.get("bitrateMode").is_none() { + settings.bitrate_mode = BitrateMode::Crf; + } } if let Some(v) = crate::commands::checked_u32_p(p, "keyframeDistance", cmd)? { settings.keyframe_distance = Some(v); diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 6c918b4c..e2ce210a 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -274,6 +274,10 @@ pub struct ExportSettings { /// stream needs is raised. pub h264_level: Option, pub bitrate_mode: BitrateMode, + /// Constant rate factor of [`BitrateMode::Crf`], 0 (best) to 51 (smallest); 23 by default, + /// 18 is visually near-lossless. + #[serde(default = "default_crf")] + pub crf: f32, /// May H.264 be encoded by the system's hardware encoder (VideoToolbox on macOS, NVENC on /// Windows)? Off unless asked for: hardware output depends on the machine, so it is not /// byte-reproducible like the built-in encoder's (`determinism_tests`). @@ -467,6 +471,7 @@ impl Default for ExportSettings { h264_profile: H264Profile::High, h264_level: None, bitrate_mode: BitrateMode::default(), + crf: DEFAULT_CRF, hardware_encoding: HardwareEncoding::default(), max_bitrate_kbps: None, adaptive_bitrate: None, @@ -485,6 +490,13 @@ impl Default for ExportSettings { } } +/// [`ExportSettings::crf`] of new settings and of settings saved before it existed. +pub const DEFAULT_CRF: f32 = 23.0; + +fn default_crf() -> f32 { + DEFAULT_CRF +} + impl ExportSettings { /// Reject settings the encoders cannot honour. pub fn validate(&self) -> Result<()> { @@ -519,6 +531,14 @@ impl ExportSettings { if self.format == Format::Hevc && self.bitrate_mode == BitrateMode::Vbr2Pass { return Err(ExportError::Unsupported("H.265 export has no two-pass mode: choose CBR or VBR, 1 pass".into())); } + if self.bitrate_mode == BitrateMode::Crf { + if self.format == Format::Hevc { + return Err(ExportError::Unsupported("H.265 export has no CRF mode: choose CBR or VBR, 1 pass".into())); + } + if !self.crf.is_finite() || !(0.0..=51.0).contains(&self.crf) { + return Err(ExportError::Unsupported("CRF must be between 0 and 51".into())); + } + } Ok(()) } } @@ -1344,6 +1364,8 @@ fn h264_factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSetti let max = s.max_bitrate_kbps.filter(|m| *m >= kbps).unwrap_or_else(|| (u64::from(kbps) * 3 / 2).min(u64::from(u32::MAX)) as u32); cfg.rate = match s.bitrate_mode { BitrateMode::Cbr => filmcraft_h264enc::RateControl::Cbr { kbps }, + // the encoder clamps to 0..=51; a NaN from a hand-edited preset becomes the default + BitrateMode::Crf => filmcraft_h264enc::RateControl::Crf(if s.crf.is_finite() { s.crf } else { DEFAULT_CRF }), _ => filmcraft_h264enc::RateControl::Vbr { target_kbps: kbps, max_kbps: max }, }; cfg.pass = match &s.h264_pass { diff --git a/crates/export/src/settings.rs b/crates/export/src/settings.rs index dad3b0e4..48f1d5e5 100644 --- a/crates/export/src/settings.rs +++ b/crates/export/src/settings.rs @@ -86,6 +86,9 @@ pub enum BitrateMode { Vbr1Pass, /// Variable bitrate, two passes (the picture is rendered and analysed first). Vbr2Pass, + /// Constant quality: every frame is coded at [`ExportSettings::crf`] and the bitrate follows the + /// picture (H.264, built-in encoder only). + Crf, } impl BitrateMode { @@ -94,6 +97,7 @@ impl BitrateMode { BitrateMode::Cbr => "CBR", BitrateMode::Vbr1Pass => "VBR, 1 pass", BitrateMode::Vbr2Pass => "VBR, 2 pass", + BitrateMode::Crf => "CRF (constant quality)", } } } @@ -504,6 +508,7 @@ impl ExportSettings { ); v += &match self.bitrate_mode { BitrateMode::Cbr => format!(", {}", mbps(r.target_kbps)), + BitrateMode::Crf => format!(" {}", self.crf), _ => format!(", Target {}, Max {}", mbps(r.target_kbps), mbps(r.max_kbps)), }; v += &format!(", keyframe every {} frames", r.keyint); diff --git a/crates/export/src/settings_tests.rs b/crates/export/src/settings_tests.rs index f0841484..63014c75 100644 --- a/crates/export/src/settings_tests.rs +++ b/crates/export/src/settings_tests.rs @@ -324,6 +324,40 @@ fn two_pass_and_cbr_h264() { } } +#[test] +fn crf_h264() { + // settings saved before CRF existed read as the default; the range is checked + let old: ExportSettings = serde_json::from_value(serde_json::json!({"format": "h264", "bitrateMode": "vbr1Pass"})).unwrap(); + assert_eq!(old.crf, DEFAULT_CRF); + let c: ExportSettings = serde_json::from_value(serde_json::json!({"bitrateMode": "crf", "crf": 18.0})).unwrap(); + assert_eq!((c.bitrate_mode, c.crf), (BitrateMode::Crf, 18.0)); + for bad in [-1.0, 51.5, f32::NAN, f32::INFINITY] { + assert!(ExportSettings { bitrate_mode: BitrateMode::Crf, crf: bad, ..Default::default() }.validate().is_err(), "{bad}"); + } + // the value only matters in CRF mode + assert!(ExportSettings { crf: f32::NAN, ..Default::default() }.validate().is_ok()); + let summary = c.summary(1920, 1080, FrameRate::FPS_25, 48_000, Tick(10 * TICKS_PER_SECOND)); + assert!(summary.video.contains("CRF (constant quality) 18"), "{}", summary.video); + + // one pass at constant quality: a lower factor spends more bits, and both decode to the picture + let (p, seq, m) = matte([0.2, 0.5, 0.8, 1.0], 320, 180, None); + let dir = Scratch::new("crf"); + let mut sizes = Vec::new(); + for crf in [10.0, 40.0] { + let path = dir.path(&format!("crf{crf}.mp4")); + let s = ExportSettings { format: Format::H264, path: path.clone(), bitrate_mode: BitrateMode::Crf, crf, include_audio: false, ..Default::default() }; + let prog = Progress::default(); + let r = export(&p, seq, &s, &m, &prog).unwrap(); + assert_eq!((r.frames, prog.total.load(Ordering::Relaxed)), (24, 24)); + let bytes: Arc<[u8]> = std::fs::read(&path).unwrap().into(); + sizes.push(bytes.len()); + let src = filmcraft_codecs::open_bytes("x.mp4", bytes).unwrap(); + let f = src.video_frame(FrameRequest::full(Tick(TICKS_PER_SECOND / 2))).unwrap().to_rgba8(); + assert!((f[2] as i32 - 204).abs() < 12 && (f[1] as i32 - 127).abs() < 12, "CRF {crf}: {:?}", &f[..4]); + } + assert!(sizes[0] > sizes[1], "CRF 10 {} bytes, CRF 40 {} bytes", sizes[0], sizes[1]); +} + #[test] fn h264_profile_level_and_keyframes() { let (p, seq, m) = matte([0.5, 0.5, 0.5, 1.0], 320, 180, None); @@ -516,6 +550,8 @@ fn h265_is_a_format_that_needs_a_registered_encoder() { let e = ExportSettings { bitrate_mode: BitrateMode::Vbr2Pass, ..h.clone() }.validate().unwrap_err(); assert!(e.to_string().contains("two-pass"), "{e}"); assert!(ExportSettings { bitrate_mode: BitrateMode::Cbr, ..h.clone() }.validate().is_ok()); + let e = ExportSettings { bitrate_mode: BitrateMode::Crf, ..h.clone() }.validate().unwrap_err(); + assert!(e.to_string().contains("CRF"), "{e}"); // formats with a built-in encoder are always available; this one only when a registered probe says so assert!(Format::ALL.iter().filter(|f| f.has_builtin_encoder()).all(|f| available(*f))); diff --git a/crates/platform/src/hardware_encode.rs b/crates/platform/src/hardware_encode.rs index 4af31781..c715e927 100644 --- a/crates/platform/src/hardware_encode.rs +++ b/crates/platform/src/hardware_encode.rs @@ -33,6 +33,9 @@ pub fn config_for(w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -> Result if s.bitrate_mode == BitrateMode::Vbr2Pass || !matches!(s.h264_pass, H264Pass::Single) { return Err("two-pass encoding".into()); } + if s.bitrate_mode == BitrateMode::Crf { + return Err("CRF (constant quality)".into()); + } if s.signal.is_hdr() { return Err("HDR".into()); } diff --git a/crates/platform/src/nvenc/export.rs b/crates/platform/src/nvenc/export.rs index a63b6a0e..4344cb52 100644 --- a/crates/platform/src/nvenc/export.rs +++ b/crates/platform/src/nvenc/export.rs @@ -162,6 +162,9 @@ fn config(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettings) - if !matches!(s.h264_pass, H264Pass::Single) || (hevc && s.bitrate_mode == BitrateMode::Vbr2Pass) { return Err("two-pass VBR".into()); } + if s.bitrate_mode == BitrateMode::Crf { + return Err("CRF (constant quality)".into()); + } // the hardware path writes the aspect ratio of H.264 only if hevc && s.pixel_aspect.is_some_and(|(n, d)| n != d) { return Err("non-square pixels".into()); @@ -357,6 +360,7 @@ mod tests { ("an unwritable colour signal", ExportSettings { signal: ColorSignal { primaries: 9, transfer: 1, matrix: 1 }, ..ok.clone() }), ("HDR with BT.709 primaries", ExportSettings { signal: ColorSignal { primaries: 1, transfer: 16, matrix: 9 }, ..ok.clone() }), ("two-pass", ExportSettings { bitrate_mode: BitrateMode::Vbr2Pass, ..ok.clone() }), + ("CRF", ExportSettings { bitrate_mode: BitrateMode::Crf, ..ok.clone() }), ("analysis pass", ExportSettings { h264_pass: H264Pass::First, ..ok.clone() }), ("interlaced", ExportSettings { field_order: FieldOrder::UpperFirst, ..ok.clone() }), ("non-square pixels", ExportSettings { pixel_aspect: Some((4, 3)), ..ok.clone() }), diff --git a/crates/platform/tests/hardware_encode.rs b/crates/platform/tests/hardware_encode.rs index 2962f670..94b1b928 100644 --- a/crates/platform/tests/hardware_encode.rs +++ b/crates/platform/tests/hardware_encode.rs @@ -106,6 +106,7 @@ fn declines_what_the_hardware_path_does_not_take() { let declined = |s: &ExportSettings, what: &str| assert!(config_for(W, H, FPS, s).is_err(), "{what} must not use the hardware encoder"); declined(&ExportSettings { hardware_encoding: HardwareEncoding::Off, ..ok.clone() }, "hardware encoding off"); declined(&ExportSettings { bitrate_mode: BitrateMode::Vbr2Pass, ..ok.clone() }, "two-pass VBR"); + declined(&ExportSettings { bitrate_mode: BitrateMode::Crf, ..ok.clone() }, "CRF"); declined(&ExportSettings { h264_pass: H264Pass::First, ..ok.clone() }, "an analysis pass"); declined(&ExportSettings { signal: filmcraft_export::ColorSignal::PQ, ..ok.clone() }, "HDR"); declined(&ExportSettings { pixel_aspect: Some((4, 3)), ..ok.clone() }, "non-square pixels"); diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index aa156831..9cc83dac 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2893,6 +2893,7 @@ Target Bitrate Velocidad de bits objetivo Adaptive ({bpp} bits per pixel) Adaptable ({bpp} bits por píxel) Maximum Bitrate Velocidad de bits máxima + Quality (CRF) Calidad (CRF) Key Frame Distance Distancia entre fotogramas clave Quality Calidad Render at Maximum Depth Procesar a la máxima profundidad @@ -3297,6 +3298,7 @@ Upper First Superior primero VBR, 1 pass VBR, 1 pasada VBR, 2 pass VBR, 2 pasadas + CRF (constant quality) CRF (calidad constante) Vimeo 1080p Full HD Vimeo 1080p Full HD Vimeo 2160p 4K Ultra HD Vimeo 2160p 4K Ultra HD Voice-over Record Grabación de voz en off diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 14a5c02d..d0c31cb1 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2887,6 +2887,7 @@ Target Bitrate ターゲットビットレート Adaptive ({bpp} bits per pixel) アダプティブ (1 ピクセルあたり {bpp} ビット) Maximum Bitrate 最大ビットレート + Quality (CRF) 品質 (CRF) Key Frame Distance キーフレーム間隔 Quality 画質 Render at Maximum Depth 最大深度でレンダリングする @@ -3291,6 +3292,7 @@ Upper First 上位フィールド優先 VBR, 1 pass VBR、1 パス VBR, 2 pass VBR、2 パス + CRF (constant quality) CRF (品質一定) Vimeo 1080p Full HD Vimeo 1080p Full HD Vimeo 2160p 4K Ultra HD Vimeo 2160p 4K Ultra HD Voice-over Record ナレーションを録音 diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 07a10488..78ac70d1 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -557,7 +557,7 @@ mod tests { ex::MxfVideoCodec::ALL.iter().for_each(|c| push(&mut out, c.label())); ex::Placement::ALL.iter().for_each(|p| push(&mut out, p.label())); [ex::FieldOrder::Progressive, ex::FieldOrder::UpperFirst, ex::FieldOrder::LowerFirst].iter().for_each(|f| push(&mut out, f.label())); - [ex::BitrateMode::Cbr, ex::BitrateMode::Vbr1Pass, ex::BitrateMode::Vbr2Pass].iter().for_each(|m| push(&mut out, m.label())); + [ex::BitrateMode::Cbr, ex::BitrateMode::Vbr1Pass, ex::BitrateMode::Vbr2Pass, ex::BitrateMode::Crf].iter().for_each(|m| push(&mut out, m.label())); [ex::H264Profile::Baseline, ex::H264Profile::Main, ex::H264Profile::High].iter().for_each(|p| push(&mut out, p.label())); crate::panels::export_mode::RANGES.iter().for_each(|(_, l)| push(&mut out, l)); crate::panels::export_mode::PARS.iter().for_each(|(l, _)| push(&mut out, l)); diff --git a/crates/ui-egui/src/panels/export_mode.rs b/crates/ui-egui/src/panels/export_mode.rs index 4838ce5f..b47fa32e 100644 --- a/crates/ui-egui/src/panels/export_mode.rs +++ b/crates/ui-egui/src/panels/export_mode.rs @@ -642,14 +642,22 @@ fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T }); } row(ui, t, tl!("Bitrate Encoding"), |ui| { - // a hardware encoder has one pass: H.265 offers no two-pass mode - let o = [BitrateMode::Cbr, BitrateMode::Vbr1Pass, BitrateMode::Vbr2Pass]; - let labels: Vec<(String, bool)> = o.iter().map(|m| (m.label().to_string(), !(hevc && *m == BitrateMode::Vbr2Pass))).collect(); + // a hardware encoder has one pass and no constant-quality mode: H.265 offers neither + let o = [BitrateMode::Cbr, BitrateMode::Vbr1Pass, BitrateMode::Vbr2Pass, BitrateMode::Crf]; + let labels: Vec<(String, bool)> = + o.iter().map(|m| (m.label().to_string(), !(hevc && matches!(m, BitrateMode::Vbr2Pass | BitrateMode::Crf)))).collect(); if let Some(i) = combo(ui, reg, "export.video.bitrateMode", s.bitrate_mode.label(), &labels, 140.0) { s.bitrate_mode = o[i]; } }); - if let Some(bpp) = s.adaptive_bitrate { + if s.bitrate_mode == BitrateMode::Crf { + row(ui, t, tl!("Quality (CRF)"), |ui| { + let mut crf = if s.crf.is_finite() { f64::from(s.crf) } else { f64::from(filmcraft_engine::export::DEFAULT_CRF) }; + if drag(ui, reg, "export.video.crf", &mut crf, 0.0..=51.0, 0.1, "", 0) { + s.crf = crf.round() as f32; + } + }); + } else if let Some(bpp) = s.adaptive_bitrate { row(ui, t, tl!("Target Bitrate"), |ui| { ui.label(egui::RichText::new(tlf!("Adaptive ({bpp} bits per pixel)", bpp)).size(12.0)); let r = ui.small_button(tl!("Set")); From 593515e884f5c0f2b1834ed07fb2cde4db77cde1 Mon Sep 17 00:00:00 2001 From: JordyUSA <75485565+JordyUSA@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:09:05 +0700 Subject: [PATCH 010/144] h264enc: CRF level fits its bitrate; stream records its settings Constant quality has no bitrate cap, but the level was chosen from the picture size and rate alone: CRF 0 exports of 1080p camera footage averaged 105 Mb/s (147 Mb/s peak) while signalling High@L4 (25 Mb/s). The level now allows a worst-case estimate of 3 bits per pixel at CRF 0, halving every 6 steps, so CRF 0 at 1080p30 signals 5.1 and the default CRF 23 stays at 4.0. The user_data_unregistered SEI on the first access unit now lists the settings x264-style (rc=crf crf=18.0 profile=high level=4.0 ...). Co-Authored-By: Claude Opus 5.5 --- crates/h264enc/src/lib.rs | 57 +++++++++++++++++++++- crates/h264enc/tests/crf_level_and_note.rs | 45 +++++++++++++++++ 2 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 crates/h264enc/tests/crf_level_and_note.rs diff --git a/crates/h264enc/src/lib.rs b/crates/h264enc/src/lib.rs index c09c8cc0..e20a91d1 100644 --- a/crates/h264enc/src/lib.rs +++ b/crates/h264enc/src/lib.rs @@ -297,7 +297,60 @@ pub struct Encoder { const LOG2_MAX_FRAME_NUM: u32 = 8; const LOG2_MAX_POC_LSB: u32 = 8; +/// The bitrate a level must allow for constant quality `crf`, which has no bitrate cap. It +/// assumes the worst footage: about 3 bits per pixel at CRF 0 (FilmCraft's CRF 0 exports of real +/// camera footage measured 1.7 on average and 2.4 in their busiest second), halving every 6 steps +/// (one doubling of the quantiser step). The default CRF 23 keeps 1080p30 at level 4. +fn crf_level_kbps(crf: f32, mbw: usize, mbh: usize, fps: f64) -> u32 { + let crf = if crf.is_finite() { f64::from(crf.clamp(0.0, 51.0)) } else { 23.0 }; + let bpp = 3.0 * (-crf / 6.0).exp2(); + let pixels = (mbw as f64) * (mbh as f64) * 256.0; + // a float-to-int `as` saturates, and NaN (a hostile fps) becomes 0 + (pixels * fps * bpp / 1000.0).ceil() as u32 +} + impl Encoder { + /// The settings that shaped the stream, x264-style (`key=value` separated by spaces), for the + /// user_data_unregistered SEI: a file says how it was made (`strings`, `ffprobe -show_data`). + fn options(&self) -> String { + let c = &self.cfg; + let rate = match c.rate { + RateControl::Qp(q) => format!("rc=cqp qp={q}"), + RateControl::Crf(f) => format!("rc=crf crf={:.1}", f.clamp(0.0, 51.0)), + RateControl::Cbr { kbps } => format!("rc=cbr bitrate={kbps}"), + RateControl::Vbr { target_kbps, max_kbps } => format!("rc=vbr bitrate={target_kbps} vbv_maxrate={max_kbps}"), + }; + let pass = match c.pass { + Pass::Single => "", + Pass::First => " pass=1", + Pass::Second(_) => " pass=2", + }; + let profile = match c.profile { + Profile::Baseline => "baseline", + Profile::Main => "main", + Profile::High => "high", + }; + let preset = match c.preset { + Preset::Speed => "speed", + Preset::Balanced => "balanced", + Preset::Quality => "quality", + }; + let level = self.sps.level_idc; + format!( + "{rate}{pass} profile={profile} level={}.{} preset={preset} keyint={} bframes={} refs={} cabac={} 8x8dct={} slices={} aq={:.2} scenecut={}", + level / 10, + level % 10, + c.keyint, + c.bframes, + self.max_refs, + u8::from(c.profile != Profile::Baseline), + u8::from(c.profile == Profile::High), + self.slices, + c.aq_strength, + u8::from(c.scenecut), + ) + } + pub fn new(mut cfg: EncoderConfig) -> Result { if cfg.width == 0 || cfg.height == 0 || !cfg.width.is_multiple_of(2) || !cfg.height.is_multiple_of(2) || cfg.width > 16384 || cfg.height > 16384 { return Err(Error::InvalidDimensions(cfg.width, cfg.height)); @@ -376,7 +429,7 @@ impl Encoder { let max_refs = if cfg.bframes > 0 { 2 } else { 1 }; let (kbps_for_level, rc_kind, vbv) = match (&cfg.rate, &cfg.pass) { (RateControl::Qp(q), _) => (None, RcKind::Qp((*q).min(51)), (None, None)), - (RateControl::Crf(c), _) => (None, RcKind::Crf(c.clamp(0.0, 51.0)), (None, None)), + (RateControl::Crf(c), _) => (Some(crf_level_kbps(*c, mbw, mbh, fps)), RcKind::Crf(c.clamp(0.0, 51.0)), (None, None)), (RateControl::Cbr { kbps }, Pass::Second(st)) => (Some(*kbps), ratecontrol::RateControl::plan_two_pass(st, *kbps), (Some(*kbps), Some(*kbps))), (RateControl::Cbr { kbps }, Pass::First) => (Some(*kbps), RcKind::Abr { kbps: *kbps }, (None, None)), (RateControl::Cbr { kbps }, Pass::Single) => (Some(*kbps), RcKind::Abr { kbps: *kbps }, (Some(*kbps), Some(*kbps))), @@ -832,7 +885,7 @@ impl Encoder { } if !self.sei_sent { self.sei_sent = true; - let text = format!("filmcraft-h264enc {} - clean-room H.264 encoder", env!("CARGO_PKG_VERSION")); + let text = format!("filmcraft-h264enc {} - clean-room H.264 encoder - options: {}", env!("CARGO_PKG_VERSION"), self.options()); nals.push(nal::nal(0, NAL_SEI, &nal::sei_user_data_rbsp(&text))); if self.cfg.mastering_display.is_some() || self.cfg.content_light.is_some() { nals.push(nal::nal(0, NAL_SEI, &nal::sei_hdr_rbsp(self.cfg.mastering_display.as_ref(), self.cfg.content_light))); diff --git a/crates/h264enc/tests/crf_level_and_note.rs b/crates/h264enc/tests/crf_level_and_note.rs new file mode 100644 index 00000000..b93ebf66 --- /dev/null +++ b/crates/h264enc/tests/crf_level_and_note.rs @@ -0,0 +1,45 @@ +//! 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 common::synth; +use filmcraft_h264enc::*; + +fn crf(w: u32, h: u32, fps: u32, f: f32) -> Encoder { + let mut c = EncoderConfig::new(w, h, fps, 1); + c.rate = RateControl::Crf(f); + c.format = PacketFormat::LengthPrefixed; + Encoder::new(c).unwrap() +} + +#[test] +fn crf_level_allows_the_bitrate_of_the_factor() { + let level = |w, h, fps, f| crf(w, h, fps, f).avcc()[3]; + // 1080p30: the default and visually lossless factors stay at 4; CRF 0 (measured at 105 Mb/s + // average, 147 Mb/s peak on camera footage) needs more than 4.2's 62.5 Mb/s + assert_eq!(level(1920, 1080, 30, 23.0), 40); + assert_eq!(level(1920, 1080, 30, 18.0), 40); + assert_eq!(level(1920, 1080, 30, 0.0), 51); + // never below what the picture size and rate need + assert_eq!(level(3840, 2160, 30, 51.0), 51); + // a hostile factor is treated as in range + assert_eq!(level(1920, 1080, 30, f32::NAN), 40); + assert_eq!(level(1920, 1080, 30, -100.0), 51); +} + +#[test] +fn first_access_unit_carries_the_settings() { + let (w, h) = (64, 48); + let mut enc = crf(w as u32, h as u32, 30, 18.0); + let f = synth(w, h, 0); + let mut packets = enc.encode(&f.frame(), 0).unwrap(); + packets.extend(enc.flush()); + let text = |p: &Packet| String::from_utf8_lossy(&p.data).into_owned(); + let first = text(&packets[0]); + for want in ["filmcraft-h264enc", "rc=crf crf=18.0", "profile=high", "level=", "bframes=2", "cabac=1"] { + assert!(first.contains(want), "{want} missing from {first:?}"); + } + // once per stream + assert!(packets.iter().skip(1).all(|p| !text(p).contains("filmcraft-h264enc"))); +} From 48e5350d301c4b0e5b4411fffab3a321f628341e Mon Sep 17 00:00:00 2001 From: gyro Date: Sat, 10 Oct 2026 03:14:18 -0700 Subject: [PATCH 011/144] Trim Monitor: Make trim drags no longer deadlock data_mut() and drag_delta() both take egui's non-reentrant context write-lock; calling the second inside the first froze the app on the first frame of a drag in the Trim Monitor. The delta is now read before the lock is taken, and a regression test drags through the real Trim Monitor. --- crates/ui-egui/src/panels/trim_monitor.rs | 5 +++- crates/ui-egui/tests/timeline_trim_ui.rs | 31 +++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/trim_monitor.rs b/crates/ui-egui/src/panels/trim_monitor.rs index 6634320c..2cb4838e 100644 --- a/crates/ui-egui/src/panels/trim_monitor.rs +++ b/crates/ui-egui/src/panels/trim_monitor.rs @@ -244,9 +244,12 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeColumn); } if resp.dragged() { + // read the delta before locking: drag_delta() takes the context's write lock, + // the same one data_mut() holds, and that lock is not reentrant (deadlock) + let dx = resp.drag_delta().x; let total = ui.ctx().data_mut(|d| { let v = d.get_temp_mut_or_default::(egui::Id::new("trim-monitor-dx")); - *v += resp.drag_delta().x; + *v += dx; *v }); let n = (total * fpp).round() as i64; diff --git a/crates/ui-egui/tests/timeline_trim_ui.rs b/crates/ui-egui/tests/timeline_trim_ui.rs index 8ea07cd2..a6be2841 100644 --- a/crates/ui-egui/tests/timeline_trim_ui.rs +++ b/crates/ui-egui/tests/timeline_trim_ui.rs @@ -298,3 +298,34 @@ fn dragging_a_caption_edge_inward_trims_it() { assert_eq!(s1, s0, "the caption's start stayed: trimmed, not moved"); assert!(e1 < e0, "the caption's end moved in: {e0} → {e1}"); } + +/// Dragging a picture in the Trim Monitor must not deadlock: the shift bookkeeping used to call +/// `drag_delta()` inside `data_mut()`, and both take egui's non-reentrant context write-lock, so +/// the drag froze the whole app on its first recognised frame (the delta is read before locking +/// now). The drag commits one normal trim on release. +#[test] +fn dragging_in_the_trim_monitor_trims_without_hanging() { + let mut d = Driver::demo(); + let city = d.v1_clip(CITY); + d.exec("trim.selectEditPoint", json!({"clip": city, "edge": "out", "kind": "trim"})); + d.frames(2); + // the Program panel shows the two-up Trim Monitor once an edit point is selected + let els = d.ok("ui.elements", json!({"prefix": "trimMonitor."})); + let out = els + .as_array() + .expect("elements") + .iter() + .find(|e| e["id"] == "trimMonitor.outgoing") + .unwrap_or_else(|| panic!("trim monitor not shown: {els}")) + .clone(); + let r: Vec = out["rect"].as_array().expect("rect").iter().map(|v| v.as_f64().expect("number") as f32).collect(); + let (cx, cy) = (r[0] + r[2] / 2.0, r[1] + r[3] / 2.0); + // about 66 px of drag = the 6 pt threshold + 60 px of motion, at 6 px per frame; + // inward (left) trims into the clip's media, outward would clamp at the seamless cut + let (_, e0) = d.span(city); + d.drag(pos2(cx, cy), pos2(cx - 66.0, cy), 33); + let (_, e1) = d.span(city); + let frames = (e1 - e0) / d.frame(); + assert!(e1 < e0, "the Out edge moved in by {} frames", frames.abs()); + assert!((6..=14).contains(&-frames), "about 11 frames expected for 60 px at 6 px/frame, moved {}", frames.abs()); +} From 2e21c4490498e4b38ea51ec0c2e389cbfb1d4a9f Mon Sep 17 00:00:00 2001 From: JordyUSA <75485565+JordyUSA@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:31:36 +0700 Subject: [PATCH 012/144] Export: never crash on WAV/AIFF over 4 GiB; stream PCM to disk WAV and AIFF sizes were 32-bit with unchecked arithmetic: past 4 GiB (24-bit 5.1 at 96 kHz after about 41 minutes) a debug build panicked and a release build wrote a wrapped, corrupt header. The whole file was also built in memory first. - WAV past 4 GiB is written as RF64 (EBU Tech 3306) with a ds64 chunk; smaller files stay plain RIFF. - AIFF has no 64-bit form: a longer one is refused before anything is written, naming WAV instead. - The header is written from the known length, then the samples one second at a time, so memory stays flat; progress advances per second. - Odd-sized data chunks get their pad byte; hostile lengths, channel counts and sample rates are errors. Co-Authored-By: Claude Opus 5.5 --- crates/export/src/audio_out.rs | 5 + crates/export/src/lib.rs | 39 +++-- crates/export/src/pcm.rs | 286 +++++++++++++++++++++++++-------- 3 files changed, 257 insertions(+), 73 deletions(-) diff --git a/crates/export/src/audio_out.rs b/crates/export/src/audio_out.rs index b92b6054..5fd2b90c 100644 --- a/crates/export/src/audio_out.rs +++ b/crates/export/src/audio_out.rs @@ -145,6 +145,11 @@ impl AudioOut { Some(buf) } + /// Output samples still to come (per channel). + pub fn remaining(&self) -> u64 { + u64::try_from(self.end.saturating_sub(self.out)).unwrap_or(0) + } + /// Everything still to come (to the end of the range). pub fn rest(&mut self, sources: &dyn SourceProvider) -> Option>> { self.pull(self.end, sources) diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 6c918b4c..2821b57b 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -1474,20 +1474,41 @@ pub fn export(project: &Arc, seq: ItemId, settings: &ExportSettings, so let batch = rayon::current_num_threads().clamp(2, 16) as i64; let (bytes, nframes) = match settings.format { Format::Wav | Format::Aiff => { + let mut a = audio_out::AudioOut::new(project.clone(), seq, settings, range)?; + let (ch, sr, bits) = (a.channels as u16, a.sr, settings.audio.bits); + let container = if settings.format == Format::Wav { pcm::PcmContainer::Wav } else { pcm::PcmContainer::Aiff }; + let frames = a.remaining(); + // the sizes are known up front: refuse an AIFF that cannot hold them before measuring + let head = pcm::header(container, ch, sr, bits, frames).map_err(ExportError::Unsupported)?; + // one second per chunk: memory stays flat however long the range is + let chunk = i64::from(sr.max(1)); if !settings.part_of_batch { - progress.total.store(1, Ordering::Relaxed); + progress.total.store(frames.div_ceil(chunk as u64).max(1), Ordering::Relaxed); progress.set_status(format!("Exporting audio ({})", settings.format.label())); } - let mut a = audio_out::AudioOut::new(project.clone(), seq, settings, range)?; a.measure(settings, sources, &cancelled)?; *progress.loudness.lock().unwrap_or_else(|e| e.into_inner()) = a.loudness; - let planar = a.rest(sources).unwrap_or_else(|| vec![Vec::new(); a.channels]); - let inter = audio_out::interleave(&planar); - let (ch, sr, bits) = (a.channels as u16, a.sr, settings.audio.bits); - let data = if settings.format == Format::Wav { pcm::write_wav(&inter, ch, sr, bits) } else { pcm::write_aiff(&inter, ch, sr, bits) }; - let n = write_output(settings, &settings.path, data)?; - progress.done.store(1, Ordering::Relaxed); - (n, planar.first().map_or(0, Vec::len) as u64) + let mut out = Out::create(settings)?; + let io = |e: std::io::Error| ExportError::Io(e.to_string()); + out.write_all(&head).map_err(io)?; + let mut buf = Vec::new(); + while let Some(planar) = a.pull(a.out.saturating_add(chunk), sources) { + if cancelled() { + return Err(ExportError::Cancelled); + } + buf.clear(); + pcm::encode(container, &audio_out::interleave(&planar), bits, &mut buf); + out.write_all(&buf).map_err(io)?; + if !settings.part_of_batch { + progress.done.fetch_add(1, Ordering::Relaxed); + } + } + out.write_all(pcm::pad(frames, ch, bits)).map_err(io)?; + let n = out.finish(settings)?; + if settings.part_of_batch { + progress.done.store(1, Ordering::Relaxed); + } + (n, frames) } Format::PngSequence | Format::TiffSequence | Format::BmpSequence | Format::Gif => { let pipe = pipeline::Pipeline::new(project.clone(), seq, settings, false)?; diff --git a/crates/export/src/pcm.rs b/crates/export/src/pcm.rs index c479a017..03b88a74 100644 --- a/crates/export/src/pcm.rs +++ b/crates/export/src/pcm.rs @@ -1,5 +1,16 @@ -//! Audio-only files: WAV (RIFF, little-endian PCM) and AIFF (big-endian PCM), 16 or 24 bit, plus -//! image-sequence file naming. +//! Audio-only files: WAV (RIFF, little-endian PCM; RF64 past 4 GiB) and AIFF (big-endian PCM), +//! 16 or 24 bit, plus image-sequence file naming. +//! +//! The header is written first from the known length ([`header`]), then the samples in chunks +//! ([`encode`]) and the pad byte ([`pad`]), so an export streams to disk instead of building the +//! file in memory. + +/// The container of an audio-only export. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PcmContainer { + Wav, + Aiff, +} /// Quantise one sample to a signed integer of `bits` (16 or 24). fn quantise(s: f32, bits: u16) -> i32 { @@ -7,41 +18,128 @@ fn quantise(s: f32, bits: u16) -> i32 { (s.clamp(-1.0, 1.0) * max).round() as i32 } +/// The sample size written for a requested `bits`: 24, else 16. +fn sample_bits(bits: u16) -> u16 { + if bits >= 24 { 24 } else { 16 } +} + +/// Bytes of sample data for `frames` frames, or None when that does not fit 64 bits. +fn data_len(frames: u64, channels: u16, bits: u16) -> Option { + frames.checked_mul(u64::from(channels))?.checked_mul(u64::from(sample_bits(bits) / 8)) +} + +/// The pad byte after an odd-sized data chunk (RIFF and AIFF chunks are word-aligned). +pub fn pad(frames: u64, channels: u16, bits: u16) -> &'static [u8] { + if data_len(frames, channels, bits).is_some_and(|n| n & 1 == 1) { &[0] } else { &[] } +} + +/// Everything before the samples of a file of `frames` frames. A WAV whose RIFF size would not +/// fit 32 bits (4 GiB: 24-bit 5.1 at 96 kHz after about 41 minutes) is written as RF64 (EBU Tech +/// 3306), with the 64-bit sizes in a `ds64` chunk. AIFF has no 64-bit form, so a longer AIFF is +/// an error naming WAV instead. +pub fn header(container: PcmContainer, channels: u16, sample_rate: u32, bits: u16, frames: u64) -> Result, String> { + let too_long = || "the audio is too long to write".to_string(); + let bits = sample_bits(bits); + let bps = bits / 8; + let data = data_len(frames, channels, bits).ok_or_else(too_long)?; + let block_align = channels.checked_mul(bps).ok_or("too many audio channels")?; + match container { + PcmContainer::Wav => { + let byte_rate = u32::try_from(u64::from(sample_rate) * u64::from(block_align)).map_err(|_| "the audio sample rate is too high".to_string())?; + let ext = channels > 2; + let fmt_len: u32 = if ext { 40 } else { 16 }; + let padded = data.checked_add(data & 1).ok_or_else(too_long)?; + // "WAVE" + fmt chunk + data chunk header + samples + let riff = (4 + 8 + u64::from(fmt_len) + 8).checked_add(padded).ok_or_else(too_long)?; + let rf64 = riff > u64::from(u32::MAX); + let mut v = Vec::with_capacity(80); + if rf64 { + const DS64: u64 = 8 + 28; + v.extend_from_slice(b"RF64"); + v.extend_from_slice(&u32::MAX.to_le_bytes()); + v.extend_from_slice(b"WAVEds64"); + v.extend_from_slice(&28u32.to_le_bytes()); + v.extend_from_slice(&riff.checked_add(DS64).ok_or_else(too_long)?.to_le_bytes()); + v.extend_from_slice(&data.to_le_bytes()); + v.extend_from_slice(&frames.to_le_bytes()); + v.extend_from_slice(&0u32.to_le_bytes()); // no table entries + } else { + v.extend_from_slice(b"RIFF"); + v.extend_from_slice(&(riff as u32).to_le_bytes()); + v.extend_from_slice(b"WAVE"); + } + v.extend_from_slice(b"fmt "); + v.extend_from_slice(&fmt_len.to_le_bytes()); + v.extend_from_slice(&(if ext { 0xFFFEu16 } else { 1 }).to_le_bytes()); + v.extend_from_slice(&channels.to_le_bytes()); + v.extend_from_slice(&sample_rate.to_le_bytes()); + v.extend_from_slice(&byte_rate.to_le_bytes()); + v.extend_from_slice(&block_align.to_le_bytes()); + v.extend_from_slice(&bits.to_le_bytes()); + if ext { + // cbSize, valid bits, channel mask, KSDATAFORMAT_SUBTYPE_PCM + v.extend_from_slice(&22u16.to_le_bytes()); + v.extend_from_slice(&bits.to_le_bytes()); + let mask: u32 = if channels == 6 { 0x3F } else { 0 }; + v.extend_from_slice(&mask.to_le_bytes()); + v.extend_from_slice(&[0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71]); + } + v.extend_from_slice(b"data"); + v.extend_from_slice(&(if rf64 { u32::MAX } else { data as u32 }).to_le_bytes()); + Ok(v) + } + PcmContainer::Aiff => { + let aiff_too_long = || "AIFF holds at most 4 GB of audio: export WAV, which has no such limit".to_string(); + let frames32 = u32::try_from(frames).map_err(|_| aiff_too_long())?; + // SSND: offset + block size + samples + let ssnd = u32::try_from(data.checked_add(8).ok_or_else(aiff_too_long)?).map_err(|_| aiff_too_long())?; + let form = (4u32 + (8 + 18)).checked_add(8).and_then(|n| n.checked_add(ssnd)).and_then(|n| n.checked_add(ssnd & 1)).ok_or_else(aiff_too_long)?; + let mut v = Vec::with_capacity(54); + v.extend_from_slice(b"FORM"); + v.extend_from_slice(&form.to_be_bytes()); + v.extend_from_slice(b"AIFFCOMM"); + v.extend_from_slice(&18u32.to_be_bytes()); + v.extend_from_slice(&channels.to_be_bytes()); + v.extend_from_slice(&frames32.to_be_bytes()); + v.extend_from_slice(&bits.to_be_bytes()); + v.extend_from_slice(&extended(sample_rate)); + v.extend_from_slice(b"SSND"); + v.extend_from_slice(&ssnd.to_be_bytes()); + v.extend_from_slice(&0u32.to_be_bytes()); // offset + v.extend_from_slice(&0u32.to_be_bytes()); // block size + Ok(v) + } + } +} + +/// Append interleaved samples as `bits`-bit PCM (little-endian for WAV, big-endian for AIFF). +pub fn encode(container: PcmContainer, interleaved: &[f32], bits: u16, out: &mut Vec) { + let bits = sample_bits(bits); + let bps = usize::from(bits / 8); + out.reserve(interleaved.len().saturating_mul(bps)); + for &s in interleaved { + let q = quantise(s, bits); + match container { + PcmContainer::Wav => out.extend_from_slice(q.to_le_bytes().get(..bps).unwrap_or_default()), + PcmContainer::Aiff => out.extend_from_slice(q.to_be_bytes().get(4 - bps..).unwrap_or_default()), + } + } +} + +/// A whole file in memory (short audio; exports stream with [`header`] and [`encode`]). +fn write_whole(container: PcmContainer, interleaved: &[f32], channels: u16, sample_rate: u32, bits: u16) -> Result, String> { + let frames = (interleaved.len() / usize::from(channels.max(1))) as u64; + let mut v = header(container, channels, sample_rate, bits, frames)?; + encode(container, interleaved, bits, &mut v); + v.extend_from_slice(pad(frames, channels, bits)); + Ok(v) +} + /// A RIFF/WAVE file of interleaved samples. More than two channels are written as /// `WAVE_FORMAT_EXTENSIBLE` with the speaker mask of the layout (6 channels: L, R, C, LFE, Ls, Rs /// = `0x3F`). -pub fn write_wav(interleaved: &[f32], channels: u16, sample_rate: u32, bits: u16) -> Vec { - let bits: u16 = if bits >= 24 { 24 } else { 16 }; - let bps = (bits / 8) as u32; - let data_len = interleaved.len() as u32 * bps; - let ext = channels > 2; - let fmt_len: u32 = if ext { 40 } else { 16 }; - let mut v = Vec::with_capacity(28 + fmt_len as usize + data_len as usize); - v.extend_from_slice(b"RIFF"); - v.extend_from_slice(&(20 + fmt_len + data_len).to_le_bytes()); - v.extend_from_slice(b"WAVEfmt "); - v.extend_from_slice(&fmt_len.to_le_bytes()); - v.extend_from_slice(&(if ext { 0xFFFEu16 } else { 1 }).to_le_bytes()); - v.extend_from_slice(&channels.to_le_bytes()); - v.extend_from_slice(&sample_rate.to_le_bytes()); - v.extend_from_slice(&(sample_rate * channels as u32 * bps).to_le_bytes()); - v.extend_from_slice(&(channels * bps as u16).to_le_bytes()); - v.extend_from_slice(&bits.to_le_bytes()); - if ext { - // cbSize, valid bits, channel mask, KSDATAFORMAT_SUBTYPE_PCM - v.extend_from_slice(&22u16.to_le_bytes()); - v.extend_from_slice(&bits.to_le_bytes()); - let mask: u32 = if channels == 6 { 0x3F } else { 0 }; - v.extend_from_slice(&mask.to_le_bytes()); - v.extend_from_slice(&[0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71]); - } - v.extend_from_slice(b"data"); - v.extend_from_slice(&data_len.to_le_bytes()); - for &s in interleaved { - let q = quantise(s, bits).to_le_bytes(); - v.extend_from_slice(&q[..bps as usize]); - } - v +pub fn write_wav(interleaved: &[f32], channels: u16, sample_rate: u32, bits: u16) -> Result, String> { + write_whole(PcmContainer::Wav, interleaved, channels, sample_rate, bits) } /// IEEE 754 80-bit extended encoding of a sample rate (AIFF `COMM`). @@ -59,34 +157,8 @@ fn extended(rate: u32) -> [u8; 10] { } /// An AIFF file (`FORM`/`AIFF`, `COMM` + `SSND`) of interleaved samples. -pub fn write_aiff(interleaved: &[f32], channels: u16, sample_rate: u32, bits: u16) -> Vec { - let bits: u16 = if bits >= 24 { 24 } else { 16 }; - let bps = (bits / 8) as usize; - let frames = interleaved.len() / channels.max(1) as usize; - let data_len = (interleaved.len() * bps) as u32; - let ssnd_len = 8 + data_len; - let form_len = 4 + (8 + 18) + (8 + ssnd_len) + (ssnd_len & 1); - let mut v = Vec::with_capacity(form_len as usize + 8); - v.extend_from_slice(b"FORM"); - v.extend_from_slice(&form_len.to_be_bytes()); - v.extend_from_slice(b"AIFFCOMM"); - v.extend_from_slice(&18u32.to_be_bytes()); - v.extend_from_slice(&channels.to_be_bytes()); - v.extend_from_slice(&(frames as u32).to_be_bytes()); - v.extend_from_slice(&bits.to_be_bytes()); - v.extend_from_slice(&extended(sample_rate)); - v.extend_from_slice(b"SSND"); - v.extend_from_slice(&ssnd_len.to_be_bytes()); - v.extend_from_slice(&0u32.to_be_bytes()); // offset - v.extend_from_slice(&0u32.to_be_bytes()); // block size - for &s in interleaved { - let q = quantise(s, bits).to_be_bytes(); - v.extend_from_slice(&q[4 - bps..]); - } - if ssnd_len & 1 == 1 { - v.push(0); - } - v +pub fn write_aiff(interleaved: &[f32], channels: u16, sample_rate: u32, bits: u16) -> Result, String> { + write_whole(PcmContainer::Aiff, interleaved, channels, sample_rate, bits) } /// File name of frame `index` of an image sequence of `count` frames exported to `path`: @@ -126,12 +198,98 @@ mod tests { #[test] fn wav_and_aiff_layout() { let s = [0.5f32, -0.5, 1.0, -1.0]; - let w = write_wav(&s, 2, 48_000, 24); + let w = write_wav(&s, 2, 48_000, 24).unwrap(); assert_eq!(w.len(), 44 + 12); assert_eq!(&w[34..36], &24u16.to_le_bytes()); - let a = write_aiff(&s, 2, 48_000, 16); + let a = write_aiff(&s, 2, 48_000, 16).unwrap(); assert_eq!(&a[..4], b"FORM"); assert_eq!(u32::from_be_bytes(a[4..8].try_into().unwrap()) as usize, a.len() - 8); assert_eq!(&a[a.len() - 2..], &(-32767i16).to_be_bytes()); } + + fn le32(b: &[u8], at: usize) -> u32 { + u32::from_le_bytes(b[at..at + 4].try_into().unwrap()) + } + + fn le64(b: &[u8], at: usize) -> u64 { + u64::from_le_bytes(b[at..at + 8].try_into().unwrap()) + } + + /// 24-bit 5.1 at 96 kHz passes 4 GiB after about 41 minutes; the sizes overflowed `u32` + /// (a panic in debug builds, a corrupt header in release). + #[test] + fn wav_past_4_gib_is_rf64() { + let frames = 96_000u64 * 60 * 60; // an hour: 6.2 GB + let h = header(PcmContainer::Wav, 6, 96_000, 24, frames).unwrap(); + let data = frames * 6 * 3; + assert_eq!(&h[..4], b"RF64"); + assert_eq!(le32(&h, 4), u32::MAX); + assert_eq!(&h[8..16], b"WAVEds64"); + assert_eq!(le32(&h, 16), 28); + // the 64-bit RIFF size covers everything after the first 8 bytes + assert_eq!(le64(&h, 20), h.len() as u64 - 8 + data); + assert_eq!((le64(&h, 28), le64(&h, 36), le32(&h, 44)), (data, frames, 0)); + assert_eq!(&h[48..52], b"fmt "); + assert_eq!(&h[h.len() - 8..h.len() - 4], b"data"); + assert_eq!(le32(&h, h.len() - 4), u32::MAX); + } + + #[test] + fn wav_up_to_4_gib_stays_riff() { + // the longest stereo 16-bit RIFF: 36 bytes of header after the size field + let max = (u64::from(u32::MAX) - 36) / 4; + let h = header(PcmContainer::Wav, 2, 48_000, 16, max).unwrap(); + assert_eq!(&h[..4], b"RIFF"); + assert_eq!(u64::from(le32(&h, 4)), 36 + max * 4); + assert_eq!(u64::from(le32(&h, 40)), max * 4); + assert_eq!(&header(PcmContainer::Wav, 2, 48_000, 16, max + 1).unwrap()[..4], b"RF64"); + } + + #[test] + fn aiff_past_4_gib_is_refused_not_wrapped() { + let e = header(PcmContainer::Aiff, 2, 48_000, 24, 48_000 * 60 * 60 * 8).unwrap_err(); + assert!(e.contains("export WAV"), "{e}"); + assert!(header(PcmContainer::Aiff, 2, 48_000, 24, 48_000 * 60 * 60).is_ok()); + } + + #[test] + fn hostile_sizes_are_errors() { + for c in [PcmContainer::Wav, PcmContainer::Aiff] { + assert!(header(c, 2, 48_000, 24, u64::MAX).is_err()); + assert!(header(c, u16::MAX, 48_000, 24, 1).is_err()); + } + assert!(header(PcmContainer::Wav, 6, u32::MAX, 24, 1).is_err()); + } + + #[test] + fn odd_data_chunks_are_padded() { + // one 24-bit mono frame: 3 bytes of data, one pad byte, counted in the RIFF size + let w = write_wav(&[0.25], 1, 48_000, 24).unwrap(); + assert_eq!((w.len(), le32(&w, 4) as usize, le32(&w, 40)), (48, 40, 3)); + let a = write_aiff(&[0.25], 1, 48_000, 24).unwrap(); + assert_eq!(u32::from_be_bytes(a[4..8].try_into().unwrap()) as usize, a.len() - 8); + assert!(pad(2, 1, 24).is_empty()); + } + + /// A reader takes the RF64 sizes: ffprobe reports the duration from `ds64` (skipped without it). + #[test] + fn ffprobe_reads_rf64_duration() { + let dir = std::env::temp_dir().join(format!("filmcraft-rf64-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("long.wav"); + let frames = 48_000u64 * 60 * 60 * 5; // 5 hours of 24-bit stereo: 5.2 GB + let mut f = header(PcmContainer::Wav, 2, 48_000, 24, frames).unwrap(); + encode(PcmContainer::Wav, &[0.0; 9_600], 24, &mut f); + std::fs::write(&path, &f).unwrap(); + let out = std::process::Command::new("ffprobe") + .args(["-v", "error", "-show_entries", "format=format_name,duration", "-of", "default=nw=1"]) + .arg(&path) + .output(); + let _ = std::fs::remove_dir_all(&dir); + let Ok(out) = out else { return }; + let text = String::from_utf8_lossy(&out.stdout); + assert!(text.contains("format_name=wav"), "{text}"); + let secs: f64 = text.lines().find_map(|l| l.strip_prefix("duration=")).and_then(|d| d.parse().ok()).unwrap_or(0.0); + assert!((secs - 18_000.0).abs() < 1.0, "{text}"); + } } From 1b83994c936df4a9f3d53ffc73d7f1f7b93da6a1 Mon Sep 17 00:00:00 2001 From: grib_job Date: Sat, 10 Oct 2026 13:40:22 +0300 Subject: [PATCH 013/144] feat: Add Russian (ru) localization (updated) --- ATTRIBUTION.md | 1 + ROADMAP.md | 2 +- apps/filmcraft/src/native_menu.rs | 2 + crates/engine/src/settings.rs | 1 + crates/ui-egui/src/i18n/mod.rs | 34 +- crates/ui-egui/src/i18n/ru.tsv | 356 +++++++++++++++++++++ crates/ui-egui/src/i18n/ru.tsv.attribution | 8 + crates/ui-egui/src/menus.rs | 5 +- 8 files changed, 404 insertions(+), 5 deletions(-) create mode 100644 crates/ui-egui/src/i18n/ru.tsv create mode 100644 crates/ui-egui/src/i18n/ru.tsv.attribution diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 3e94fa45..ce7bd609 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -9,6 +9,7 @@ iconography, images or other Adobe assets; open licences only; every asset attri | Asset | Author | Source | Licence | |---|---|---|---| +| `crates/ui-egui/src/i18n/ru.tsv` | FilmCraft contributors | Original work: clean-room Russian interface translations | MIT OR Apache-2.0 | | `crates/ui-egui/src/i18n/uk.tsv` | FilmCraft contributors | Original work: clean-room Ukrainian interface translations | MIT OR Apache-2.0 | | `assets/fonts/Inter-Regular.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) | | `assets/fonts/Inter-Medium.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) | diff --git a/ROADMAP.md b/ROADMAP.md index 7a32f549..e8e5f928 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -211,4 +211,4 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining ### Interface localization -Edit > Language switches English/Japanese/Spanish/Portuguese (Brazilian)/Ukrainian and persists across restarts through `general.interfaceLanguage` (also available in Settings > General). Until a language is chosen, System Language (the default) follows the operating system's or browser's preferred languages and falls back to English. Commands `app.language.english`, `app.language.japanese`, `app.language.spanish`, `app.language.portuguese` and `app.language.ukrainian` are reachable through the control channel. Translations live in one catalog per language (`crates/ui-egui/src/i18n/.tsv`); UI code wraps its strings in `tl!("…")` / `tlf!("…{name}", name)`, and names that come from registries (effects and their parameters, settings, commands, panels, workspaces, presets) are translated where they are drawn. **Spanish and Japanese cover the whole interface**: menus, panels, dialogs, the Settings pages, the Effects/Effect Controls registry, the Keyboard Shortcuts dialog and status messages; tests fail when a `tl!` literal, a menu label or a registry name has no Spanish entry (`ja.tsv` has the same entries, but the tests do not require them yet). Searches match the displayed labels as well as English sources. Brazilian Portuguese and Ukrainian cover the core menus; untranslated labels use English. Ukrainian uses the bundled Cyrillic interface fonts. Engine error messages, the control channel, the CLI and MCP stay in English. Japanese text uses the Japanese fonts from [craft-fonts](https://github.com/storytold/craft-fonts) when FilmCraft is built with them (`CRAFT_FONTS_DIR`; all official releases, including the web build), otherwise a Japanese font already installed on the system; with neither, switching to Japanese is refused with a message. Existing vertical text support is preserved. +Edit > Language switches English/Japanese/Spanish/Portuguese (Brazilian)/Ukrainian/Russian and persists across restarts through `general.interfaceLanguage` (also available in Settings > General). Until a language is chosen, System Language (the default) follows the operating system's or browser's preferred languages and falls back to English. Commands `app.language.english`, `app.language.japanese`, `app.language.spanish`, `app.language.portuguese`, `app.language.ukrainian` and `app.language.russian` are reachable through the control channel. Translations live in one catalog per language (`crates/ui-egui/src/i18n/.tsv`); UI code wraps its strings in `tl!("…")` / `tlf!("…{name}", name)`, and names that come from registries (effects and their parameters, settings, commands, panels, workspaces, presets) are translated where they are drawn. **Spanish and Japanese cover the whole interface**: menus, panels, dialogs, the Settings pages, the Effects/Effect Controls registry, the Keyboard Shortcuts dialog and status messages; tests fail when a `tl!` literal, a menu label or a registry name has no Spanish entry (`ja.tsv` has the same entries, but the tests do not require them yet). Searches match the displayed labels as well as English sources. Brazilian Portuguese, Ukrainian and Russian cover the core menus; untranslated labels use English. Ukrainian and Russian use the bundled Cyrillic interface fonts. Engine error messages, the control channel, the CLI and MCP stay in English. Japanese text uses the Japanese fonts from [craft-fonts](https://github.com/storytold/craft-fonts) when FilmCraft is built with them (`CRAFT_FONTS_DIR`; all official releases, including the web build), otherwise a Japanese font already installed on the system; with neither, switching to Japanese is refused with a message. Existing vertical text support is preserved. diff --git a/apps/filmcraft/src/native_menu.rs b/apps/filmcraft/src/native_menu.rs index c63f8295..65aa26b5 100644 --- a/apps/filmcraft/src/native_menu.rs +++ b/apps/filmcraft/src/native_menu.rs @@ -101,6 +101,8 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver, Sho filmcraft_ui_egui::i18n::Language::PtBr } else if checked("app.language.ukrainian") { filmcraft_ui_egui::i18n::Language::Uk + } else if checked("app.language.russian") { + filmcraft_ui_egui::i18n::Language::Ru } else { filmcraft_ui_egui::i18n::Language::En }; diff --git a/crates/engine/src/settings.rs b/crates/engine/src/settings.rs index 9243483f..e2deb5b8 100644 --- a/crates/engine/src/settings.rs +++ b/crates/engine/src/settings.rs @@ -687,6 +687,7 @@ static CATEGORIES: &[Category] = &[ ("es", "Español"), ("pt-br", "Português (Brasil)"), ("uk", "Українська"), + ("ru", "Русский"), ]), true, ), diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 3f4b845e..71c58151 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -35,15 +35,17 @@ pub enum Language { #[serde(rename = "pt-br")] PtBr, Uk, + Ru, } static JAPANESE: OnceLock = OnceLock::new(); static SPANISH: OnceLock = OnceLock::new(); static PORTUGUESE: OnceLock = OnceLock::new(); static UKRAINIAN: OnceLock = OnceLock::new(); +static RUSSIAN: OnceLock = OnceLock::new(); impl Language { - pub const ALL: [Self; 5] = [Self::En, Self::Ja, Self::Es, Self::PtBr, Self::Uk]; + pub const ALL: [Self; 6] = [Self::En, Self::Ja, Self::Es, Self::PtBr, Self::Uk, Self::Ru]; pub fn name(self) -> &'static str { match self { @@ -52,6 +54,7 @@ impl Language { Self::Es => "Español", Self::PtBr => "Português (Brasil)", Self::Uk => "Українська", + Self::Ru => "Русский", } } @@ -62,6 +65,7 @@ impl Language { "es" => Some(Self::Es), "pt-br" => Some(Self::PtBr), "uk" => Some(Self::Uk), + "ru" => Some(Self::Ru), _ => None, } } @@ -70,7 +74,7 @@ impl Language { /// (BCP 47 or POSIX locale tags such as `es-419`, `pt_BR.UTF-8`, most preferred first) that the /// interface has, else English. Any Portuguese gets the Brazilian catalog, the only one there is. pub fn from_locales(tags: &[String]) -> Self { - const PRIMARY: [(&str, Language); 5] = [("en", Language::En), ("ja", Language::Ja), ("es", Language::Es), ("pt", Language::PtBr), ("uk", Language::Uk)]; + const PRIMARY: [(&str, Language); 6] = [("en", Language::En), ("ja", Language::Ja), ("es", Language::Es), ("pt", Language::PtBr), ("uk", Language::Uk), ("ru", Language::Ru)]; tags.iter() .find_map(|tag| { let primary = tag.split(['-', '_', '.', '@']).next().unwrap_or_default(); @@ -87,6 +91,7 @@ impl Language { Self::Es => "es", Self::PtBr => "pt-br", Self::Uk => "uk", + Self::Ru => "ru", } } @@ -98,6 +103,7 @@ impl Language { Self::Es => Some(SPANISH.get_or_init(|| Catalog::parse(include_str!("es.tsv")))), Self::PtBr => Some(PORTUGUESE.get_or_init(|| Catalog::parse(include_str!("pt-br.tsv")))), Self::Uk => Some(UKRAINIAN.get_or_init(|| Catalog::parse(include_str!("uk.tsv")))), + Self::Ru => Some(RUSSIAN.get_or_init(|| Catalog::parse(include_str!("ru.tsv")))), } } @@ -253,7 +259,7 @@ mod tests { #[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"))] + [("es", include_str!("es.tsv")), ("ja", include_str!("ja.tsv")), ("pt-br", include_str!("pt-br.tsv")), ("uk", include_str!("uk.tsv")), ("ru", include_str!("ru.tsv"))] { let (entries, errors) = catalog::parse_entries(text); assert!(errors.is_empty(), "{code}: {errors:?}"); @@ -280,6 +286,10 @@ mod tests { 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::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"); for l in Language::ALL { assert_eq!(Language::parse(l.code()), Some(l)); let json = serde_json::to_string(&l).unwrap(); @@ -732,6 +742,24 @@ mod tests { } } + #[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}"); + } + let (portuguese, _) = catalog::parse_entries(include_str!("pt-br.tsv")); + for (_, en, _) in portuguese { + 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}"); + } + } + #[test] fn ukrainian_renders_with_bundled_fonts() { let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv new file mode 100644 index 00000000..b8daace5 --- /dev/null +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -0,0 +1,356 @@ +# Russian (ru) catalog. Columns separated by TAB: context source translation +# Empty context is a plain UI string. Escapes: \n, \t, \\; preserve placeholders and trailing … +# Original clean-room translations from the meaning of FilmCraft's English menu labels, +# using Russian video-editing vocabulary (Premiere-style). No proprietary localization resources were used. +# Core menu coverage; other interface strings fall back to English. + + About FilmCraft О FilmCraft + Add Audio Submix Track Добавить дорожку подмикширования звука + Add Caption at Playhead Добавить субтитр в позиции курсора воспроизведения + Add Chapter Marker… Добавить маркер главы… + Add Edit Добавить склейку + Add Edit to All Tracks Добавить склейку на все дорожки + Add Flash Cue Marker… Добавить маркер Flash Cue… + Add Frame Hold Добавить стоп-кадр + Add Guide… Добавить направляющую… + Add Marker Добавить маркер + Add New Caption Track… Добавить новую дорожку субтитров… + Add Range Marker Добавить маркер диапазона + Add Range Marker to In and Out Добавить маркер диапазона между точками входа и выхода + Add Tracks… Добавить дорожки… + Adjustment Layer… Корректирующий слой… + Align to Selection Выровнять по выделению + Align to Video Frame Выровнять по видеокадру + Align to Video Frame as Group Выровнять как группу по видеокадру + All Panels Все панели + Alpha Альфа + Appearance Оформление + Appearance… Оформление… + Apply Audio Transition Применить аудиопереход + Apply Default Transitions to Selection Применить переходы по умолчанию к выделению + Apply Video Transition Применить видеопереход + Arrange Упорядочить + ArtCraft Website Веб-сайт ArtCraft + Assembly Сборка + Attach Proxies… Прикрепить прокси… + Audio Аудио + Audio Channels… Аудиоканалы… + Audio Clip Mixer Микшер аудиоклипов + Audio Gain… Усиление звука… + Audio Hardware… Аудиооборудование… + Audio In Вход аудио + Audio Meters Индикаторы звука + Audio Options Параметры аудио + Audio Out Выход аудио + Audio Track Mixer Микшер аудиодорожек + Audio Waveform Форма звуковой волны + Auto Save… Автосохранение… + Automate to Sequence… Автоматизировать в эпизод… + Bars and Tone… Цветные полосы и тон… + Bin Подборка + Bin From Selection Подборка из выделения + Black Video… Черное видео… + Blue Синий + Bottom По нижнему краю + Breakout to Mono Разбить на моно + Bring Forward Переместить вперед + Bring to Front Переместить на передний план + Brown Коричневый + Captions Субтитры + Captions and Graphics Субтитры и графика + Captions… Субтитры… + Caribbean Карибский + Center Horizontally По центру по горизонтали + Center Vertically По центру по вертикали + Cerulean Лазурный + Clear Очистить + Clear Guides Очистить направляющие + Clear In Снять точку входа + Clear In and Out Снять точки входа и выхода + Clear Markers Удалить маркеры + Clear Out Снять точку выхода + Clear Selected Marker Удалить выбранный маркер + Clip Клип + Close Закрыть + Close All Other Projects Закрыть все остальные проекты + Close All Projects Закрыть все проекты + Close Gap Удалить пробел + Close Project Закрыть проект + Color Management… Управление цветом… + Color Matte… Цветная подложка… + Comparison View Режим сравнения + Composite Video Составное видео + Consolidate Duplicates Объединить дубликаты + Copy Копировать + Copy Paste Includes Sequence Markers Копирование и вставка включают маркеры эпизода + Create Captions from Transcript… Создать субтитры из расшифровки… + Create Multi-Camera Source Sequence… Создать многокамерный исходный эпизод… + Create Proxies… Создать прокси… + Cut Вырезать + Dark Темный + Darkest Самый темный + Delete Render Files Удалить файлы рендеринга + Delete Render Files In to Out Удалить файлы рендеринга между точками входа и выхода + Delete Tracks… Удалить дорожки… + Delete Transcript Удалить расшифровку + Demo Footage Демонстрационные материалы + Demo Project Демо-проект + Deselect All Отменить выделение + Detach Proxies Отсоединить прокси + Display Mode Режим отображения + Distribute Распределить + Distribute Horizontally Распределить по горизонтали + Distribute Space Horizontally Распределить интервалы по горизонтали + Distribute Space Vertically Распределить интервалы по вертикали + Distribute Vertically Распределить по вертикали + Duplicate Дублировать + Dynamic Audio Waveforms Динамические формы звуковой волны + Edit Правка + Edit Offline… Редактировать автономный клип… + Edit Original Редактировать оригинал + Edit Subclip… Редактировать субклип… + Editing Монтаж + Effect Controls Элементы управления эффектами + Effects Эффекты + Ellipse Эллипс + Enable Включить + Enable Remix Включить ремикс + Essential Graphics Основные графические элементы + Essential Sound Основной звук + Events События + Export Экспорт + Export As Motion Graphics Template… Экспортировать как шаблон анимированной графики… + Extract Извлечь + Extract Audio Извлечь аудио + Field Options… Параметры полей… + File Файл + File… Файл… + Fill frame Заполнить кадр + FilmCraft Help… Справка FilmCraft… + FilmCraft on GitHub FilmCraft на GitHub + FilmCraft on getartcraft.com FilmCraft на getartcraft.com + Find Next Найти далее + Find… Найти… + Fit По размеру + Fit to frame Вписать в кадр + Flatten Свести + Forest Лесной + Frame Blending Смешение кадров + Frame Hold Options… Параметры стоп-кадра… + Frame Sampling Выборка кадров + From Bin Из подборки + From Source Monitor Из монитора источника + From Source Monitor, Match Frame Из монитора источника, сопоставление кадров + From file… Из файла… + Full Полное + Generate Audio Waveform Сгенерировать форму звуковой волны + Get Media File Properties for Свойства медиафайла для + Go to Gap Перейти к промежутку + Go to In Перейти к точке входа + Go to Next Caption Segment Перейти к следующему сегменту субтитров + Go to Next Marker Перейти к следующему маркеру + Go to Out Перейти к точке выхода + Go to Previous Caption Segment Перейти к предыдущему сегменту субтитров + Go to Previous Marker Перейти к предыдущему маркеру + Go to Split Перейти к раздельной точке + Graphics and Titles Графика и титры + Graphics… Графика… + Green Зеленый + Group Сгруппировать + Guide Templates Шаблоны направляющих + Help Справка + Hide All Caption Tracks Скрыть все дорожки субтитров + High Quality Playback Высокое качество воспроизведения + History История + Import From Импортировать из + Import Image Sequence… Импортировать последовательность изображений… + Import from Media Browser Импортировать из медиабраузера + Import… Импорт… + Info Инфо + Ingest Settings… Параметры инжеста… + Insert Вставить + Insert Frame Hold Segment Вставить сегмент стоп-кадра + Install Motion Graphics Template… Установить шаблон анимированной графики… + Interpret Footage… Интерпретировать материал… + Join the ArtCraft Discord… Присоединиться к ArtCraft Discord… + Keyboard Shortcuts… Сочетания клавиш… + Label Метка + Labels… Метки… + Language Язык + Lavender Лавандовый + Learning Обучение + Left По левому краю + Libraries Библиотеки + Lift Поднять + Light Светлый + Link Связать + Link Media… Привязать медиаданные… + Linked Selection Связанное выделение + Lock Guides Закрепить направляющие + Lumetri Color Цвет Lumetri + Lumetri Scopes Видеографика Lumetri + Magnification Масштаб + Make Offline… Сделать автономным… + Make Subclip… Создать субклип… + Make Subsequence Создать вложенный эпизод + Manage Guides… Управление направляющими… + Mark Clip Пометить клип + Mark In Отметить точку входа + Mark Out Отметить точку выхода + Mark Selection Пометить выделение + Mark Split Отметить раздельную точку + Markers Маркеры + Match Frame Сопоставить кадр + Media Analysis & Transcription… Анализ медиа и расшифровка… + Media Browser Медиабраузер + Media Cache… Кэш медиаданных… + Media… Медиа… + Memory… Память… + Merge Clips… Объединить клипы… + Metadata Метаданные + Modify Изменить + Motion Graphics Template… Шаблон анимированной графики… + Multi-Camera Мультикамера + Multi-Camera View Мультикамерный режим + Nest… Вложить… + New Создать + New Layer Новый слой + New Project Panel Новая панель проекта + Next in Sequence Следующий в эпизоде + Next in Track Следующий на дорожке + Normalize Mix Track… Нормализовать мастер-дорожку… + Offline File… Автономный файл… + Open Project… Открыть проект… + Optical Flow Оптический поток + Overwrite Перезаписать + Paste Вставить + Paste Attributes… Вставить атрибуты… + Paste Insert Вставить со сдвигом + Paused Resolution Разрешение на паузе + Playback Resolution Разрешение воспроизведения + Playback… Воспроизведение… + Plugins… Плагины… + Polygon Многоугольник + Preferences Настройки + Previous in Sequence Предыдущий в эпизоде + Previous in Track Предыдущий на дорожке + Program Программа + Progress Прогресс + Project Проект + Project Manager… Диспетчер проектов… + Project Settings Настройки проекта + Project… Проект… + Properties Свойства + Purple Пурпурный + Reconnect Full Resolution Media… Переподключить медиаданные полного разрешения… + Recover Unsaved Changes… Восстановить несохраненные изменения… + Rectangle Прямоугольник + Red Красный + Redo Повторить + Reference Monitor Контрольный монитор + Remix Ремикс + Remix Properties… Свойства ремикса… + Remove Attributes… Удалить атрибуты… + Remove Filler Words Удалить слова-паразиты + Remove Pauses Удалить паузы + Remove Unused Удалить неиспользуемые + Rename… Переименовать… + Render Audio Рендеринг аудио + Render Effects In to Out Рендеринг эффектов от точки входа до точки выхода + Render In to Out Рендеринг от точки входа до точки выхода + Render Selection Рендеринг выделения + Replace Fonts in Projects… Заменить шрифты в проектах… + Replace With Clip Заменить клипом + Report an Issue… Сообщить о проблеме… + Reset All Parameters Сбросить все параметры + Reset Duration Сбросить длительность + Reset to Saved Layout Сбросить к сохраненному макету + Restore Captions from Source Clip Восстановить субтитры из исходного клипа + Reveal Log Files… Показать файлы журнала… + Reverse Match Frame Обратное сопоставление кадра + Revert Вернуть к сохраненному + Revert Remix Отменить ремикс + Review Рецензирование + Right По правому краю + Ripple Delete Удаление со сдвигом + Ripple Sequence Markers Сдвигать маркеры эпизода + Rose Розовый + Safe Margins Безопасные поля + Save Сохранить + Save All Сохранить все + Save As… Сохранить как… + Save Guides as Template… Сохранить направляющие как шаблон… + Save a Copy… Сохранить копию… + Save as Template… Сохранить как шаблон… + Scale to Frame Size Масштабировать по размеру кадра + Scene Edit Detection… Обнаружение склеек сцен… + Scratch Disks… Рабочие диски… + Search Bin Поисковая подборка + Select Выделение + Select All Выделить все + Select All Matching Выделить все совпадающие + Select Label Group Выделить группу по метке + Select Next Graphic Выбрать следующий графический элемент + Select Next Layer Выбрать следующий слой + Select Previous Graphic Выбрать предыдущий графический элемент + Select Previous Layer Выбрать предыдущий слой + Selection Follows Playhead Выделение следует за курсором воспроизведения + Selection as FilmCraft Project… Выделение как проект FilmCraft… + Selection… Выделение… + Send Backward Переместить назад + Send to Back Переместить на задний план + Sequence Эпизод + Sequence From Clip Эпизод из клипа + Sequence Settings… Настройки эпизода… + Sequence… Эпизод… + Settings Параметры + Show Active Caption Tracks Only Показать только активные дорожки субтитров + Show All Caption Tracks Показать все дорожки субтитров + Show All Marker Colors Показать все цвета маркеров + Show Guides Показать направляющие + Show Rulers Показать линейки + Show Through Edits Показать сквозные склейки + Simplify Sequence… Упростить эпизод… + Snap in Program Monitor Привязка в программном мониторе + Snap in Timeline Привязка на таймлайне + Source Источник + Source Settings… Настройки источника… + Speed/Duration… Скорость/Длительность… + Synchronize… Синхронизировать… + System Compatibility Report… Отчет о совместимости системы… + Tan Рыжевато-коричневый + Teal Бирюзовый + Text Текст + Time Interpolation Интерполяция времени + Timecode Тайм-код + Timecode… Тайм-код… + Timeline Таймлайн + Timeline… Таймлайн… + Toggle Proxies Переключить прокси + Tools Инструменты + Top По верхнему краю + Transcribe Sequence… Транскрибировать эпизод… + Transcribe… Транскрибировать… + Transcript Расшифровка + Transparent Video… Прозрачное видео… + Trim Edit Режим подрезки + Trim… Подрезка… + Undo Отменить + Ungroup Разгруппировать + Universal Counting Leader… Универсальный ракорд… + Update Metadata… Обновить метаданные… + Upgrade Caption to Graphic Преобразовать субтитр в графику + Upgrade to Source Graphic Преобразовать в исходную графику + Vertical Text Вертикальный текст + Video In Вход видео + Video Options Параметры видео + Video Out Выход видео + Video and Audio Waveform Split Раздельное отображение видео и аудиоволны + View Просмотр + Violet Фиолетовый + Window Окно + Workspaces Рабочие среды + Yellow Желтый + Zoom In Увеличить + Zoom Out Уменьшить + Zoom to Sequence Масштабировать по размеру эпизода diff --git a/crates/ui-egui/src/i18n/ru.tsv.attribution b/crates/ui-egui/src/i18n/ru.tsv.attribution new file mode 100644 index 00000000..23c128ac --- /dev/null +++ b/crates/ui-egui/src/i18n/ru.tsv.attribution @@ -0,0 +1,8 @@ +asset: ru.tsv +title: Russian interface translation catalog +author: FilmCraft contributors +source: original work +license: MIT OR Apache-2.0 +license-file: LICENSE-MIT and LICENSE-APACHE +added: 2026-10-10 by grib_job +notes: Original English-to-Russian menu translations using clean-room NLE vocabulary. diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 5cac050e..581af9a3 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -27,6 +27,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ uic!("app.language.spanish", "Español", ["Edit", "Language"], None), uic!("app.language.portuguese", "Português (Brasil)", ["Edit", "Language"], None), uic!("app.language.ukrainian", "Українська", ["Edit", "Language"], None), + uic!("app.language.russian", "Русский", ["Edit", "Language"], None), uic!("source.playback.toggle", "Source Play/Stop", [], None), uic!("source.playback.play", "Play Source", [], None), uic!("source.playback.stop", "Stop Source", [], None), @@ -170,7 +171,7 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: let object = params.as_object_mut().ok_or("command parameters must be an object")?; object.insert("target".into(), json!("source")); } - if matches!(id, "app.language.english" | "app.language.japanese" | "app.language.spanish" | "app.language.portuguese" | "app.language.ukrainian") { + if matches!(id, "app.language.english" | "app.language.japanese" | "app.language.spanish" | "app.language.portuguese" | "app.language.ukrainian" | "app.language.russian") { // Japanese needs the craft-fonts (built with CRAFT_FONTS_DIR) or a font installed on the system if id == "app.language.japanese" && !crate::i18n::install_japanese_font(ctx) { return Err("no Japanese font is installed on this system (for example Noto Sans CJK JP); the interface stays in English".into()); @@ -180,6 +181,7 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: "app.language.spanish" => crate::i18n::Language::Es, "app.language.portuguese" => crate::i18n::Language::PtBr, "app.language.ukrainian" => crate::i18n::Language::Uk, + "app.language.russian" => crate::i18n::Language::Ru, _ => crate::i18n::Language::En, }; // The preference is updated in memory before it is written, so a failed write (read-only @@ -551,6 +553,7 @@ pub fn menu_items(app: &FilmcraftApp) -> Vec { "app.language.spanish" => it.checked = Some(app.ui.language == crate::i18n::Language::Es), "app.language.portuguese" => it.checked = Some(app.ui.language == crate::i18n::Language::PtBr), "app.language.ukrainian" => it.checked = Some(app.ui.language == crate::i18n::Language::Uk), + "app.language.russian" => it.checked = Some(app.ui.language == crate::i18n::Language::Ru), _ => {} } if it.id.starts_with("view.") { From a72d3fe1b97494ec65fe7f8a4a9c80f77b38c4ac Mon Sep 17 00:00:00 2001 From: Katt <238968166+KattKnight@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:26:07 +0530 Subject: [PATCH 014/144] Media: Automatically refresh growing files works (and catches re-renders) Settings > Media > Automatically refresh growing files and Refresh growing files every were listed but not wired. The desktop app now stamps each file media item's file (size + modification time) on a worker thread every N seconds and as soon as its window comes back to the front, and reads changed files again the way import opens them: the item gets the new MediaInfo (length, frame size) and identity, and the media pool swaps in a new source (the old one indexes the file as it was). Not an undo step and a clean project stays clean (bump_view). This also covers a render written over the file a clip uses (an EffectCraft or After Effects round trip). A file that can't be opened yet is tried again. media.refreshChanged runs one look (CLI, MCP). Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/lib.rs | 10 ++ crates/engine/src/media_watch.rs | 169 +++++++++++++++++++++++++ crates/engine/src/media_watch_tests.rs | 100 +++++++++++++++ crates/engine/src/relink.rs | 10 ++ crates/engine/src/settings.rs | 4 +- crates/ui-egui/src/lib.rs | 37 ++++++ 7 files changed, 329 insertions(+), 2 deletions(-) create mode 100644 crates/engine/src/media_watch.rs create mode 100644 crates/engine/src/media_watch_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index bd80f88b..f3865ea0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -159,6 +159,7 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining ## Log +- **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-08 (Source range dragging):** implicit full-clip In/Out, video-only/audio-only/linked both controls, picture/waveform drag gestures and one-command undoable timeline placement. Shared Source/Program draggable frame-bounded handles and range translation, cancellation, Source marker/navigation routing and marked-range playback/looping. - **2026-10-08 (Source playback):** normal forward playback for video/audio media and subclips in the Source monitor, an independent Source clock, Play/Pause and focused Space, frame stepping, device-clock audio with wall-clock fallback, seeking, and end-of-clip stopping. Windows desktop review verified a real video file and standalone WAV, including In/Out marking. Reverse/shuttle Source playback and source sequences remain unsupported; no parity percentage changed. - **2026-10-08 (night):** HDR H.265 (HEVC Main 10, PQ and HLG, BT.2020, limited range) export on Windows through NVENC (#30): an HDR sequence now exports as real 10-bit HDR (`rgbf_to_yuv420_10` into P010 input buffers; VUI BT.2020 + PQ / HLG; the HDR10 mastering-display and content-light messages as SEI on every IDR for PQ, through the generic SEI payload array because NVENC 12.1 has no mastering-display field; `colr` / `mdcv` / `clli` in the sample entry), where the GPU reports 10-bit HEVC (new `register_hdr_probe` / `hdr_available` in the export crate; elsewhere, and on macOS, HDR sequences still tone-map to 8-bit SDR HEVC, and `settings.sdr` forces that). ffprobe reads `hevc` / `Main 10` / `hvc1` / `yuv420p10le` / `bt2020` / `smpte2084` or `arib-std-b67` / `bt2020nc` / `tv` with the mastering-display and content-light side data for PQ; ffmpeg's decode equals ours code for code; the in-process round trip is at 71.1 dB luma PSNR on the 10-bit scale with 803 distinct levels on a ramp and the peak code 940 intact (RTX 5060). 8-bit SDR H.265 is unchanged. diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 26df0e69..13b48227 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -27,6 +27,7 @@ mod marker_export; pub mod masks; pub mod media_browser; pub mod media_pool; +pub mod media_watch; pub mod mixer; pub mod multicam; pub mod panels; @@ -382,6 +383,11 @@ pub struct Session { pub browser: media_browser::BrowserState, /// Nesting depth of [`Session::execute`] (commands that run other commands). exec_depth: u32, + /// Each media file's size and modification time when last seen (Settings ▸ Media ▸ + /// Automatically refresh growing files, [`media_watch`]). + pub media_stamps: std::collections::HashMap, + /// The stamps being taken on a worker thread ([`Session::start_media_scan`]). + pub media_scan: Option, } /// An export advanced a batch at a time on the host's thread ([`Session::pump_jobs`]). @@ -469,6 +475,8 @@ impl Session { log: Default::default(), browser: Default::default(), exec_depth: 0, + media_stamps: Default::default(), + media_scan: None, } } @@ -1065,6 +1073,8 @@ mod media_browser_tests; #[cfg(test)] mod media_test_util; #[cfg(test)] +mod media_watch_tests; +#[cfg(test)] mod mixer_tests; #[cfg(test)] mod multicam_tests; diff --git a/crates/engine/src/media_watch.rs b/crates/engine/src/media_watch.rs new file mode 100644 index 00000000..b951f1f4 --- /dev/null +++ b/crates/engine/src/media_watch.rs @@ -0,0 +1,169 @@ +//! Settings ▸ Media ▸ Automatically refresh growing files: media files that change on disk while +//! the project is open are read again, whether they are still being written (a recording, an +//! ingest) or were replaced (a render from EffectCraft or After Effects written over the file a +//! clip uses). +//! +//! Each file media item's file is compared by size and modification time with what it was when +//! last seen. The desktop app takes the stamps every *Refresh growing files every* seconds and as +//! soon as its window comes back to the front, on a worker thread so a file on a slow network share +//! never holds up the interface ([`Session::start_media_scan`], [`Session::poll_media_scan`]); +//! `media.refreshChanged` takes them on the spot (CLI, agents). +//! +//! Refreshing opens the file again: the item gets the file's new [`MediaInfo`] (a longer +//! duration, another frame size) and identity (so relinking compares against the new file), the +//! media pool swaps in the new source (the old one still indexes the file as it was), and the +//! view is refreshed without an undo step or modifying a clean project ([`Session::bump_view`]): +//! the file changed, not the edit. A file seen for the first time is only remembered; one that +//! can't be opened yet keeps its old stamp, so the next look tries again. +//! +//! [`MediaInfo`]: filmcraft_media::MediaInfo + +use std::collections::{BTreeSet, HashMap}; +use std::sync::{Arc, Mutex, PoisonError}; + +use filmcraft_project::{ItemId, ItemKind, MediaRef, Project}; +use serde_json::{Value, json}; + +use crate::{Result, Session}; + +/// A file's size and modification time (nanoseconds since 1970). +pub type Stamp = (u64, u128); + +/// Stamps of the project's media files taken on a worker thread. +pub type MediaScan = Arc)>>>>; + +#[cfg(not(target_arch = "wasm32"))] +fn stamp(path: &str) -> Option { + let meta = std::fs::metadata(path).ok()?; + let modified = meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?; + Some((meta.len(), modified.as_nanos())) +} + +#[cfg(target_arch = "wasm32")] +fn stamp(_: &str) -> Option { + None +} + +/// The files of `p`'s file media items (made-offline ones aside). +pub fn media_files(p: &Project) -> BTreeSet { + p.items + .values() + .filter_map(|i| match &i.kind { + ItemKind::Media(m) if !m.offline => match &m.media { + MediaRef::File { path } => Some(path.clone()), + _ => None, + }, + _ => None, + }) + .collect() +} + +/// `media.refreshChanged`: stamp the project's media files now and read the changed ones again. +pub(crate) fn refresh_changed(s: &mut Session, _: &Value) -> Result { + let now = media_files(&s.project).into_iter().map(|p| (p.clone(), stamp(&p))).collect(); + s.apply_media_stamps(now) +} + +impl Session { + /// Start stamping the project's media files on a worker thread, unless a look is still + /// running (or the build has no files to look at: the web app). + pub fn start_media_scan(&mut self) { + if self.media_scan.is_some() || cfg!(target_arch = "wasm32") { + return; + } + let files = media_files(&self.project); + if files.is_empty() { + return; + } + let slot: MediaScan = Arc::default(); + let out = slot.clone(); + let spawned = std::thread::Builder::new().name("media-watch".into()).spawn(move || { + let stamps = files.into_iter().map(|p| (p.clone(), stamp(&p))).collect(); + *out.lock().unwrap_or_else(PoisonError::into_inner) = Some(stamps); + }); + if spawned.is_ok() { + self.media_scan = Some(slot); + } + } + + /// Apply a finished look (`None` while there is none, or it is still running). + pub fn poll_media_scan(&mut self) -> Option> { + let stamps = self.media_scan.as_ref()?.lock().unwrap_or_else(PoisonError::into_inner).take()?; + self.media_scan = None; + Some(self.apply_media_stamps(stamps)) + } + + /// Compare the media files' stamps with the ones last seen and read the changed files + /// again. Returns `{refreshed: [item ids]}`. + fn apply_media_stamps(&mut self, now: Vec<(String, Option)>) -> Result { + // Only the files the project uses now are remembered. + let before: HashMap = std::mem::take(&mut self.media_stamps); + let mut changed = BTreeSet::new(); + for (path, stamp) in now { + match (before.get(&path), stamp) { + // Not there right now (an app saving by delete and rename): keep the last stamp. + (Some(old), None) => { + self.media_stamps.insert(path, *old); + } + (old, Some(new)) => { + if old.is_some_and(|o| *o != new) { + changed.insert(path.clone()); + } + self.media_stamps.insert(path, new); + } + (None, None) => {} + } + } + if changed.is_empty() { + return Ok(json!({"refreshed": []})); + } + let mut fresh = Vec::new(); + let mut failed = BTreeSet::new(); + for (id, it) in &self.project.items { + let Some(m) = it.as_media().filter(|m| !m.offline) else { continue }; + let MediaRef::File { path } = &m.media else { continue }; + if !changed.contains(path) || failed.contains(path) { + continue; + } + // Opened the way import opens it, so the info is what a new import would get. + match self.media.open_file(path, &*self.services) { + Ok(src) => { + let mut info = src.info().clone(); + if info.kind == filmcraft_media::MediaKind::Still + && let Some(v) = info.video.as_mut() + { + v.frame_rate = crate::settings::timebase_rate(&self.prefs.media.indeterminate_timebase); + } + let identity = crate::relink::identity_of(&*self.services, path).ok(); + fresh.push((*id, path.clone(), info, identity, src)); + } + Err(_) => { + failed.insert(path.clone()); + } + } + } + // Not readable yet (still being written): the next look tries again. + for path in &failed { + if let Some(old) = before.get(path) { + self.media_stamps.insert(path.clone(), *old); + } + } + if fresh.is_empty() { + return Ok(json!({"refreshed": []})); + } + let p = Arc::make_mut(&mut self.project); + let mut refreshed: Vec = Vec::new(); + for (id, path, info, identity, src) in fresh { + if let Some(m) = p.item_mut(id).and_then(|i| i.as_media_mut()) { + m.info = info; + if identity.is_some() { + m.identity = identity; + } + } + self.media.insert_file(id, &path, src); + refreshed.push(id); + } + self.bump_view(); + Ok(json!({"refreshed": refreshed.iter().map(|i| i.0).collect::>()})) + } +} diff --git a/crates/engine/src/media_watch_tests.rs b/crates/engine/src/media_watch_tests.rs new file mode 100644 index 00000000..44c9b409 --- /dev/null +++ b/crates/engine/src/media_watch_tests.rs @@ -0,0 +1,100 @@ +//! Media files changing on disk while the project is open (Settings ▸ Media ▸ Automatically +//! refresh growing files, `media_watch`): a re-rendered movie is read again (new length, new +//! picture, new identity) without an undo step, a file still being written is tried again, and +//! the background look does the same. + +use filmcraft_media::DemoScene; +use serde_json::json; + +use crate::Session; +use crate::media_test_util::{frame_rgba, make_movie, psnr, session_with, tmp_dir}; + +const W: u32 = 160; +const H: u32 = 90; + +/// Write a movie over `path` so its modification time moves even on coarse file systems. +fn rerender(path: &std::path::Path, scene: DemoScene, frames: i64) { + std::thread::sleep(std::time::Duration::from_millis(30)); + make_movie(path, scene, W, H, frames); +} + +fn refresh(s: &mut Session) -> serde_json::Value { + s.execute("media.refreshChanged", json!({})).unwrap() +} + +/// What frame 3 of a fresh import of `scene` looks like. +fn reference(name: &str, scene: DemoScene, frames: i64) -> Vec { + let root = tmp_dir(name); + let f = root.join("ref.mov"); + make_movie(&f, scene, W, H, frames); + let (mut r, _, _) = session_with(&[&f]); + frame_rgba(&mut r, 3, 1.0).2 +} + +#[test] +fn a_rerendered_movie_is_read_again_without_an_undo_step() { + let root = tmp_dir("watch-rerender"); + let a = root.join("a.mov"); + make_movie(&a, DemoScene::Aurora, W, H, 12); + let (mut s, items, _) = session_with(&[&a]); + let item = items[0]; + let before = frame_rgba(&mut s, 3, 1.0).2; + let length = s.project.item(item).unwrap().duration(); + s.saved_revision = s.revision; + let undo = s.history.undo.len(); + // First look: remembered, nothing to read. + assert_eq!(refresh(&mut s), json!({"refreshed": []})); + rerender(&a, DemoScene::CityNight, 24); + assert_eq!(refresh(&mut s), json!({"refreshed": [item.0]})); + let m = s.project.item(item).unwrap().as_media().unwrap().clone(); + assert_eq!(m.info.duration.0, length.0 * 2, "the new length"); + assert_eq!(m.identity, crate::relink::identity_of(&*s.services, &a.to_string_lossy()).ok(), "relinking compares with the new file"); + let after = frame_rgba(&mut s, 3, 1.0).2; + assert!(psnr(&after, &reference("watch-rerender-ref", DemoScene::CityNight, 24)) > 40.0, "the new picture"); + assert!(psnr(&after, &before) < 30.0, "not the old one"); + assert_eq!(s.history.undo.len(), undo, "the file changed, not the edit"); + assert!(!s.is_dirty()); + assert_eq!(refresh(&mut s), json!({"refreshed": []}), "acted on once"); +} + +#[test] +fn a_file_still_being_written_is_tried_again() { + let root = tmp_dir("watch-partial"); + let a = root.join("a.mov"); + make_movie(&a, DemoScene::Aurora, W, H, 12); + let (mut s, items, _) = session_with(&[&a]); + refresh(&mut s); + // The other app has only written the start of the file so far. + let full = root.join("full.mov"); + make_movie(&full, DemoScene::CityNight, W, H, 24); + let bytes = std::fs::read(&full).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(30)); + std::fs::write(&a, &bytes[..64]).unwrap(); + assert_eq!(refresh(&mut s), json!({"refreshed": []})); + std::thread::sleep(std::time::Duration::from_millis(30)); + std::fs::write(&a, &bytes).unwrap(); + assert_eq!(refresh(&mut s), json!({"refreshed": [items[0].0]})); +} + +#[test] +fn a_background_look_reads_changed_media_again() { + let root = tmp_dir("watch-scan"); + let a = root.join("a.mov"); + make_movie(&a, DemoScene::Aurora, W, H, 12); + let (mut s, items, _) = session_with(&[&a]); + let look = |s: &mut Session| { + s.start_media_scan(); + let t0 = std::time::Instant::now(); + loop { + if let Some(r) = s.poll_media_scan() { + return r.unwrap(); + } + assert!(t0.elapsed().as_secs() < 10, "the look never finished"); + std::thread::yield_now(); + } + }; + assert_eq!(look(&mut s), json!({"refreshed": []})); + rerender(&a, DemoScene::CityNight, 24); + assert_eq!(look(&mut s), json!({"refreshed": [items[0].0]})); + assert!(s.media_scan.is_none()); +} diff --git a/crates/engine/src/relink.rs b/crates/engine/src/relink.rs index 7037404a..95c8a7ef 100644 --- a/crates/engine/src/relink.rs +++ b/crates/engine/src/relink.rs @@ -653,6 +653,16 @@ pub fn commands() -> Vec { run: relink, journal: true, }, + CommandSpec { + id: "media.refreshChanged", + label: "Refresh Changed Media", + menu: &[], + shortcut: None, + params: "{}", + enabled: always, + run: crate::media_watch::refresh_changed, + journal: true, + }, CommandSpec { id: "media.replaceFootage", label: "Replace Footage…", diff --git a/crates/engine/src/settings.rs b/crates/engine/src/settings.rs index 2f020835..10db5c87 100644 --- a/crates/engine/src/settings.rs +++ b/crates/engine/src/settings.rs @@ -964,9 +964,9 @@ static CATEGORIES: &[Category] = &[ Row::Group( "Growing Files", &[ - b("media.refreshGrowingFiles", "Automatically refresh growing files", false), + b("media.refreshGrowingFiles", "Automatically refresh growing files", true), b("media.resumeGrowingPlayback", "Automatically resume playback for growing files in Source Monitor", false), - sub("media.growingRefreshSeconds", "Refresh growing Files Every", int(1.0, 3600.0, "seconds"), "media.refreshGrowingFiles", false), + sub("media.growingRefreshSeconds", "Refresh growing Files Every", int(1.0, 3600.0, "seconds"), "media.refreshGrowingFiles", true), ], ), b("media.hardwareDecoding", "Enable hardware accelerated decoding (requires restart)", false), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d5437e97..f3cf83c3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1384,6 +1384,41 @@ impl FilmcraftApp { // ---------------------------------------------------------------- frame + /// Settings ▸ Media ▸ Automatically refresh growing files: every *Refresh growing files every* + /// seconds, and as soon as the window comes back to the front (after a render in another app), + /// stamp the project's media files on a worker thread and read the changed ones again + /// ([`filmcraft_engine::media_watch`]). + #[cfg(not(target_arch = "wasm32"))] + fn tick_media_refresh(&mut self, ctx: &egui::Context) { + if let Some(r) = self.session.poll_media_scan() { + match r { + Ok(r) => { + if let Some(n) = r["refreshed"].as_array().map(Vec::len).filter(|n| *n > 0) { + self.toast = Some((format!("Refreshed {n} media item(s) changed on disk"), ctx.input(|i| i.time))); + } + } + Err(e) => self.toast = Some((e.to_string(), ctx.input(|i| i.time))), + } + } + let media = &self.session.prefs.media; + let (on, every) = (media.refresh_growing_files, f64::from(media.growing_refresh_seconds.max(1))); + let (last_key, focus_key) = (egui::Id::new("mediaWatch.lastScan"), egui::Id::new("mediaWatch.focused")); + let now = ctx.input(|i| i.time); + let focused = ctx.input(|i| i.focused); + let was_focused: bool = ctx.data(|d| d.get_temp(focus_key)).unwrap_or(focused); + ctx.data_mut(|d| d.insert_temp(focus_key, focused)); + if !on || filmcraft_engine::media_watch::media_files(&self.session.project).is_empty() { + return; + } + let last: f64 = ctx.data(|d| d.get_temp(last_key)).unwrap_or(f64::NEG_INFINITY); + if now - last >= every || (focused && !was_focused) { + ctx.data_mut(|d| d.insert_temp(last_key, now)); + self.session.start_media_scan(); + } + let wait = if self.session.media_scan.is_some() { 0.1 } else { (every - (now - last)).clamp(0.1, every) }; + ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait)); + } + fn frame(&mut self, ui: &mut egui::Ui) { i18n::set_current(self.ui.language); if std::mem::take(&mut self.panic_next_frame) { @@ -1400,6 +1435,8 @@ impl FilmcraftApp { } self.sync_pool(); self.apply_prefs(&ctx); + #[cfg(not(target_arch = "wasm32"))] + self.tick_media_refresh(&ctx); for ev in self.session.drain_events() { match ev { filmcraft_engine::Event::OpenSequence(_) => { From b5965dc5683f508b21fad378f8db0c84b1055f23 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:57:20 +0200 Subject: [PATCH 015/144] Let a bin be cleared from its context menu, the trash button, the panel menu and Delete (#587) --- crates/ui-egui/src/lib.rs | 10 +++++++- crates/ui-egui/src/panels/project.rs | 10 +++++--- crates/ui-egui/src/panels/project_views.rs | 28 ++++++++++++++++++++++ 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d5437e97..527c3184 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1258,8 +1258,16 @@ impl FilmcraftApp { if self.ui.focused == PanelKind::Project && matches!(id.as_str(), "edit.selectAll" | "edit.deselectAll") { id = id.replacen("edit.", "project.", 1); } + // Clear in the Project panel removes the selected bin when no item is selected (#587). + let clear = if self.ui.focused == PanelKind::Project && id == "project.delete" { + crate::panels::project_views::clear_params(&self.session.state.project_selection, self.ui.project_panel.selected_bin) + } else { + None + }; // Mark In/Out in the Source monitor when it has focus. - let params = if self.ui.focused == PanelKind::Source + let params = if let Some(p) = clear { + p + } else if self.ui.focused == PanelKind::Source && (matches!(id.as_str(), "markers.markIn" | "markers.markOut" | "markers.clearInOut") || id.starts_with("markers.markSplit") || id.starts_with("markers.goToSplit")) diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 09b04069..e859f539 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -609,7 +609,10 @@ fn footer(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, v: &View, action let parent = (v.bin != app.session.project.root.id).then_some(v.bin.0); actions.push(("file.newBin".into(), json!({"name": "New Bin", "parent": parent}))); } - "project.delete" if app.session.state.project_selection.is_empty() => app.ui.status = tl!("Select items to clear").into(), + "project.delete" => match crate::panels::project_views::clear_params(&app.session.state.project_selection, app.ui.project_panel.selected_bin) { + Some(p) => actions.push((id.into(), p)), + None => app.ui.status = tl!("Select items to clear").into(), + }, _ => actions.push((id.into(), json!({}))), } } @@ -673,6 +676,7 @@ pub fn panel_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui) -> bool { let inst = shown_inst(app); let v = view_of(app, inst); let has_sel = !app.session.state.project_selection.is_empty() || app.ui.project_panel.selected_bin.is_some(); + let clear = crate::panels::project_views::clear_params(&app.session.state.project_selection, app.ui.project_panel.selected_bin); let has_project_path = app.session.path.is_some(); let sc = |id: &str| app.session.shortcuts.primary(id); let (s_close, s_save, s_bin, s_find) = (sc("file.closeProject"), sc("file.save"), sc("file.newBin"), sc("edit.find")); @@ -712,8 +716,8 @@ pub fn panel_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui) -> bool { if item(ui, "rename", tl!("Rename"), has_sel, None) { actions.push(("projectPanel.rename".into(), json!({}))); } - if item(ui, "delete", tl!("Delete"), !app.session.state.project_selection.is_empty(), Some("Backspace")) { - actions.push(("project.delete".into(), json!({}))); + if item(ui, "delete", tl!("Delete"), clear.is_some(), Some("Backspace")) { + actions.push(("project.delete".into(), clear.unwrap_or_default())); } ui.separator(); if item(ui, "automateToSequence", tl!("Automate to Sequence…"), !app.session.state.project_selection.is_empty(), None) { diff --git a/crates/ui-egui/src/panels/project_views.rs b/crates/ui-egui/src/panels/project_views.rs index 7a54afae..fbfe6922 100644 --- a/crates/ui-egui/src/panels/project_views.rs +++ b/crates/ui-egui/src/panels/project_views.rs @@ -314,6 +314,20 @@ fn bin_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &View, bin: u64, actio actions.push(("file.newBin".into(), json!({"name": "New Bin", "parent": bin}))); ui.close(); } + ui.separator(); + if ui.button(tl!("Clear")).clicked() { + actions.push(("project.delete".into(), json!({"items": [bin]}))); + ui.close(); + } +} + +/// Parameters for `project.delete`: the selected items, else the selected bin by id (the engine +/// deletes a named bin with everything in it). `None` when nothing is selected. +pub fn clear_params(selection: &[filmcraft_project::ItemId], selected_bin: Option) -> Option { + if !selection.is_empty() { + return Some(json!({})); + } + selected_bin.map(|b| json!({"items": [b]})) } /// Drop project items dragged onto a bin: move them (the selection when the dragged item is in it). @@ -1033,3 +1047,17 @@ fn canvas_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &View, bin: Option< ui.separator(); background_menu(app, ui, v, bin.filter(|b| *b != app.session.project.root.id.0), actions); } + +#[cfg(test)] +mod clear_tests { + use super::clear_params; + use filmcraft_project::ItemId; + use serde_json::json; + + #[test] + fn clear_falls_back_to_the_selected_bin() { + assert_eq!(clear_params(&[], Some(7)), Some(json!({"items": [7]}))); + assert_eq!(clear_params(&[ItemId(3)], Some(7)), Some(json!({}))); + assert_eq!(clear_params(&[], None), None); + } +} From b81792c4531cb909168f7bebce9da15b218713f9 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:04:46 +0200 Subject: [PATCH 016/144] Space out browser downloads so image-sequence exports are not dropped (#378) --- apps/filmcraft-web/src/download_pacing.rs | 24 +++++++++++++++++++ apps/filmcraft-web/src/fs.rs | 20 ++++++++++++---- apps/filmcraft-web/src/lib.rs | 1 + apps/filmcraft-web/tests/download_pacing.rs | 26 +++++++++++++++++++++ 4 files changed, 67 insertions(+), 4 deletions(-) create mode 100644 apps/filmcraft-web/src/download_pacing.rs create mode 100644 apps/filmcraft-web/tests/download_pacing.rs diff --git a/apps/filmcraft-web/src/download_pacing.rs b/apps/filmcraft-web/src/download_pacing.rs new file mode 100644 index 00000000..01f9f421 --- /dev/null +++ b/apps/filmcraft-web/src/download_pacing.rs @@ -0,0 +1,24 @@ +//! Spacing of browser downloads (pure, so it is tested natively in `tests/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 +//! (one file per frame) delivered only about half of its frames (#378). Each download is given a +//! slot [`GAP_MS`] after the previous one. + +/// Milliseconds between two download clicks. +pub const GAP_MS: f64 = 125.0; + +/// Hands out click times. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Pacer { + next_free: f64, +} + +impl Pacer { + /// Reserve the next slot at time `now` (ms); returns how long to wait before clicking. + pub fn reserve(&mut self, now: f64) -> f64 { + let start = now.max(self.next_free); + self.next_free = start + GAP_MS; + start - now + } +} diff --git a/apps/filmcraft-web/src/fs.rs b/apps/filmcraft-web/src/fs.rs index adf417ca..416fbf9d 100644 --- a/apps/filmcraft-web/src/fs.rs +++ b/apps/filmcraft-web/src/fs.rs @@ -54,6 +54,7 @@ struct Fs { thread_local! { static FS: RefCell = RefCell::new(Fs::default()); + static DOWNLOADS: RefCell = RefCell::new(crate::download_pacing::Pacer::default()); } /// Wake-up hook for arriving data. @@ -405,7 +406,8 @@ impl Services for WebServices { } } -/// Offer bytes as a browser download named `name`. +/// Offer bytes as a browser download named `name`. Downloads are clicked at least +/// [`crate::download_pacing::GAP_MS`] apart: Chrome drops a burst of them (#378). pub fn download(name: &str, data: &[u8]) -> Result<(), wasm_bindgen::JsValue> { let doc = web_sys::window().and_then(|w| w.document()).ok_or("no document")?; let blob = bytes_to_blob(data, "application/octet-stream")?; @@ -414,10 +416,20 @@ pub fn download(name: &str, data: &[u8]) -> Result<(), wasm_bindgen::JsValue> { a.set_href(&url); a.set_download(file_name(name)); a.style().set_property("display", "none")?; - doc.body().ok_or("no body")?.append_child(&a)?; - a.click(); - a.remove(); + let delay = DOWNLOADS.with(|d| d.borrow_mut().reserve(js_sys::Date::now())); + let name = name.to_owned(); wasm_bindgen_futures::spawn_local(async move { + if delay > 0.0 { + sleep(delay as i32).await; + } + let clicked = match doc.body() { + Some(body) => body.append_child(&a).map(|_| a.click()), + None => Err("no body".into()), + }; + a.remove(); + if let Err(e) = clicked { + log::warn!("download {name}: {e:?}"); + } sleep(60_000).await; let _ = web_sys::Url::revoke_object_url(&url); }); diff --git a/apps/filmcraft-web/src/lib.rs b/apps/filmcraft-web/src/lib.rs index d769ea4b..21512ccc 100644 --- a/apps/filmcraft-web/src/lib.rs +++ b/apps/filmcraft-web/src/lib.rs @@ -20,6 +20,7 @@ pub mod api; pub mod audio; +pub mod download_pacing; pub mod fs; pub mod import; pub mod opfs; diff --git a/apps/filmcraft-web/tests/download_pacing.rs b/apps/filmcraft-web/tests/download_pacing.rs new file mode 100644 index 00000000..893f40d3 --- /dev/null +++ b/apps/filmcraft-web/tests/download_pacing.rs @@ -0,0 +1,26 @@ +//! 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; + +use download_pacing::{GAP_MS, Pacer}; + +/// #378: 20 downloads requested in the same instant are spread out, not clicked in one burst. +#[test] +fn burst_of_downloads_is_spaced_out() { + let mut p = Pacer::default(); + let delays: Vec = (0..20).map(|_| p.reserve(1000.0)).collect(); + assert_eq!(delays[0], 0.0); + for (i, d) in delays.iter().enumerate() { + assert_eq!(*d, i as f64 * GAP_MS); + } +} + +/// A download after a quiet period is not delayed. +#[test] +fn download_after_a_pause_is_immediate() { + let mut p = Pacer::default(); + assert_eq!(p.reserve(0.0), 0.0); + assert_eq!(p.reserve(10_000.0), 0.0); +} From de6ab8d2395fe01c6af8331f1d9affe1c21990a2 Mon Sep 17 00:00:00 2001 From: Katt <238968166+KattKnight@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:07:43 +0530 Subject: [PATCH 017/144] Media: translate Refresh Changed Media (es, ja, zh-CN) Portuguese and Ukrainian catalogs only hold menu labels, so they keep the English name like the other menu-less commands. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/zh-cn.tsv | 1 + 3 files changed, 3 insertions(+) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 8d15cde2..a0546d03 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -192,6 +192,7 @@ Light Claro Link Vincular Link Media… Vincular medios… + Refresh Changed Media Actualizar medios cambiados Linked Selection Selección vinculada Lock Guides Bloquear guías Lumetri Color Color Lumetri diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index aa86c493..2c96adc2 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3501,3 +3501,4 @@ Solid べた塗り Clear Transition トランジションを消去 Set Transition Duration トランジションのデュレーションを設定 + Refresh Changed Media 変更されたメディアを更新 diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index 476c4159..67223944 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -187,6 +187,7 @@ Light 浅 Link 链接 Link Media… 链接媒体… + Refresh Changed Media 刷新已更改的媒体 Linked Selection 关联选择 Lock Guides 锁定参考线 Lumetri Color Lumetri 颜色 From 696f428faf57ffcbe0ebb0a74600099eda2cbbd8 Mon Sep 17 00:00:00 2001 From: JordyUSA <75485565+JordyUSA@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:24:37 +0700 Subject: [PATCH 018/144] #374: trim preview keeps the waveform in place instead of squeezing it While an edge was dragged, the Timeline drew the clip at its new length from the untrimmed item, so draw_waveform spread the clip's whole source range over the shorter (or longer) box: the waveform squeezed and the sound under the pointer moved, which made it hard to trim to where the music starts (#374, item 13). Trim and roll previews now draw from the item the edit will leave, so every timeline time still covered shows the same media and the waveform is cut off at the moving edge. Video thumbnails show the new first frame the same way. Rate Stretch, which really fits the same media into a new length, still squeezes. The item update is factored out of trim and ripple_trim_group into filmcraft_edit::move_edge, which the preview uses too, so preview and edit agree for speed-changed, reversed and frame-held clips. Rendered mid-drag against the released result, the music clip's waveform matched in 15% of pixel columns before and 100% after. Co-Authored-By: Claude Opus 5.5 --- crates/edit/src/lib.rs | 60 +++++++-------- crates/edit/src/tests.rs | 37 +++++++++ crates/ui-egui/src/panels/timeline.rs | 95 ++++++++++++++++++++++-- crates/ui-egui/tests/timeline_trim_ui.rs | 34 +++++++++ 4 files changed, 188 insertions(+), 38 deletions(-) diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 2f5a8382..2c7dc21e 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -569,6 +569,31 @@ pub fn clamp_trim(seq: &Sequence, clip: ClipId, edge: Edge, mode: TrimMode, delt Ok(d) } +/// What moving one edge by `d` (already clamped) does to the item itself: its length, the media +/// it starts at (a reversed clip consumes media from its other end; a frame hold has none to +/// move), and, for the In edge with `move_start`, where it starts. The trims and the timeline's +/// live trim preview share it, so the preview shows the media the edit will keep (#374). +pub fn move_edge(it: &mut TrackItem, edge: Edge, d: Tick, move_start: bool) { + let speed = it.speed.abs(); + match edge { + Edge::In => { + if !it.reverse && it.frame_hold.is_none() { + it.source_in += src_of(d, speed); + } + it.duration -= d; + if move_start { + it.start += d; + } + } + Edge::Out => { + if it.reverse { + it.source_in -= src_of(d, speed); + } + it.duration += d; + } + } +} + /// Trim one edge of an item (Selection-tool edge drag = Regular; Ripple tool = Ripple). /// Linked partners should be trimmed by the caller with the same delta. pub fn trim(seq: &mut Sequence, clip: ClipId, edge: Edge, mode: TrimMode, delta: Tick, ctx: &mut EditCtx) -> Result { @@ -580,27 +605,10 @@ pub fn trim(seq: &mut Sequence, clip: ClipId, edge: Edge, mode: TrimMode, delta: let old_end = it.end(); let old_start = it.start; let own_link = it.link; - let speed = it.speed.abs(); let mut work = seq.clone(); { let (_, it) = work.find_item_mut(clip).ok_or(EditError::NoItem(clip))?; - match edge { - Edge::In => { - if !it.reverse && it.frame_hold.is_none() { - it.source_in += src_of(d, speed); - } - it.duration -= d; - if mode == TrimMode::Regular { - it.start += d; - } - } - Edge::Out => { - if it.reverse { - it.source_in -= src_of(d, speed); - } - it.duration += d; - } - } + move_edge(it, edge, d, mode == TrimMode::Regular); } if mode == TrimMode::Ripple { let (at, shift) = match edge { @@ -658,25 +666,11 @@ pub fn ripple_trim_group(seq: &mut Sequence, clips: &[ClipId], edge: Edge, delta let mut origins: Vec<(TrackId, Tick)> = Vec::new(); for c in clips { let (tid, it) = work.find_item_mut(*c).ok_or(EditError::NoItem(*c))?; - let speed = it.speed.abs(); let from = match edge { Edge::In => it.start + Tick(1), Edge::Out => it.end(), }; - match edge { - Edge::In => { - if !it.reverse && it.frame_hold.is_none() { - it.source_in += src_of(d, speed); - } - it.duration -= d; - } - Edge::Out => { - if it.reverse { - it.source_in -= src_of(d, speed); - } - it.duration += d; - } - } + move_edge(it, edge, d, false); if !origins.iter().any(|(t, _)| *t == tid) { origins.push((tid, from)); } diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index ae258cb0..dc0a3e20 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -749,3 +749,40 @@ fn roll_reversed_clips_keeps_media_mapping() { let (_, r) = fx.seq.find_item(ib).unwrap(); assert_eq!((r.start, r.duration, r.source_in), (f(40), f(20), f(50))); } + +/// #374: moving an edge must not change which media sits under any timeline time the clip still +/// covers (the timeline's live trim preview draws its waveform from this item), and must leave the +/// item as the trim itself does. +#[test] +fn moving_an_edge_keeps_the_media_under_the_timeline() { + let fx = Fx::new(); + let v1 = fx.v(0); + for (speed, reverse, hold) in [(1.0, false, None), (2.0, false, None), (0.5, false, None), (1.0, true, None), (2.0, true, None), (1.0, false, Some(f(50)))] + { + for (edge, d) in [(Edge::In, f(7)), (Edge::In, -f(4)), (Edge::Out, -f(9)), (Edge::Out, f(3))] { + let mut fresh = Fx::new(); + let c = fresh.put(v1, 100, 40, 200); + { + let it = fresh.seq.find_item_mut(c).unwrap().1; + (it.speed, it.reverse, it.frame_hold) = (speed, reverse, hold); + } + let before = fresh.seq.find_item(c).unwrap().1.clone(); + let mut moved = before.clone(); + move_edge(&mut moved, edge, d, true); + let label = format!("speed {speed} reverse {reverse} hold {hold:?} {edge:?} {d:?}"); + // every frame still covered shows the same media as before the move + let mut t = moved.start.max(before.start); + while t < moved.end().min(before.end()) { + let (a, b) = (before.source_time_at(t), moved.source_time_at(t)); + assert!((a - b).0.abs() <= 1, "{label}: at {t:?} {a:?} became {b:?}"); + t += f(1); + } + // and the item is what the real (unclamped) trim makes of it + let mut n = fresh.next; + let got = trim(&mut fresh.seq, c, edge, TrimMode::Regular, d, &mut Fx::ctx(&mut n)).unwrap(); + assert_eq!(got, d, "{label}: not clamped"); + let trimmed = fresh.seq.find_item(c).unwrap().1; + assert_eq!((trimmed.start, trimmed.duration, trimmed.source_in), (moved.start, moved.duration, moved.source_in), "{label}"); + } + } +} diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index d5997839..c1b3c69d 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -370,7 +370,10 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let visible = (layout.tick_at(content.min.x - 2.0), layout.tick_at(content.max.x + 2.0)); let selection: Vec = app.session.state.selection.clone(); let mut previews: HashMap)> = HashMap::new(); // live drag preview: (start, dur, track) - preview_drag(app, &seq, &layout, &mut previews); + // clips whose media changes in the preview (a trimmed or rolled edge): drawn from the edited + // item, so the waveform and thumbnail stay where the media is instead of squeezing (#374) + let mut edited: HashMap = HashMap::new(); + preview_drag(app, &seq, &mut previews, &mut edited); for r in &rows { let Some(tr) = seq.track(r.track) else { continue }; let clip_rect = if r.kind == TrackKind::Video { vclip } else { aclip }; @@ -381,6 +384,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let p = painter.with_clip_rect(Rect::from_min_max(pos2(content.min.x, row.min.y), pos2(content.max.x, row.max.y))); let volume_line = r.kind == TrackKind::Audio && !r.lane; for it in &tr.items { + let it = edited.get(&it.id).unwrap_or(it); let (start, dur, moved_track) = previews.get(&it.id).copied().unwrap_or((it.start, it.duration, None)); if moved_track.is_some_and(|m| m != r.track) { continue; @@ -1518,7 +1522,14 @@ pub fn locate(app: &FilmcraftApp, clip: u64, edge: Option<&str>) -> Option<(f32, Some((x, y)) } -fn preview_drag(app: &FilmcraftApp, seq: &Sequence, _layout: &Layout, out: &mut HashMap)>) { +fn preview_drag(app: &FilmcraftApp, seq: &Sequence, out: &mut HashMap)>, edited: &mut HashMap) { + // the item as the edge move leaves it, and its place in the preview + let mut moved = |it: &TrackItem, edge: filmcraft_edit::Edge, delta: Tick| { + let mut e = it.clone(); + filmcraft_edit::move_edge(&mut e, edge, delta, true); + out.insert(e.id, (e.start, e.duration, None)); + edited.insert(e.id, e); + }; let Some(d) = &app.tl.drag else { return }; match d { Drag::Move { clips, offset, track_delta, .. } => { @@ -1534,7 +1545,15 @@ fn preview_drag(app: &FilmcraftApp, seq: &Sequence, _layout: &Layout, out: &mut out.insert(*clip, (it.start, it.duration + *delta, None)); } } - Drag::Trim { clip, edge, delta, .. } | Drag::Stretch { clip, edge, delta, .. } => { + Drag::Trim { clip, edge, delta, .. } => { + for c in filmcraft_engine::commands::with_links(&app.session, &[*clip]) { + if let Some((_, it)) = seq.find_item(c) { + moved(it, *edge, *delta); + } + } + } + // a rate stretch keeps the same media in a new length: that one does squeeze + Drag::Stretch { clip, edge, delta, .. } => { let ids = filmcraft_engine::commands::with_links(&app.session, &[*clip]); for c in ids { if let Some((_, it)) = seq.find_item(c) { @@ -1548,10 +1567,10 @@ fn preview_drag(app: &FilmcraftApp, seq: &Sequence, _layout: &Layout, out: &mut } Drag::Roll { left, right, delta, .. } => { if let Some((_, l)) = seq.find_item(*left) { - out.insert(*left, (l.start, l.duration + *delta, None)); + moved(l, filmcraft_edit::Edge::Out, *delta); } if let Some((_, r)) = seq.find_item(*right) { - out.insert(*right, (r.start + *delta, r.duration - *delta, None)); + moved(r, filmcraft_edit::Edge::In, *delta); } } Drag::Slide { clip, delta } => { @@ -2464,6 +2483,72 @@ pub(crate) fn chain_starts(start: Tick, durations: &[Tick]) -> Vec { .collect() } +/// #374: trimming an audio clip must cut its waveform off at the moving edge, not squeeze the whole +/// waveform into the shorter clip: the preview draws from the item the trim will leave, which shows +/// the same media at every timeline time it still covers. +#[cfg(test)] +mod trim_preview_tests { + use super::*; + + fn demo() -> (FilmcraftApp, Sequence) { + let mut s = filmcraft_engine::Session::default(); + s.execute("file.openDemoProject", serde_json::json!({})).unwrap(); + let seq = s.active_sequence().unwrap().clone(); + (FilmcraftApp::new(s), seq) + } + + fn assert_same_media(before: &TrackItem, after: &TrackItem) { + let mut t = after.start.max(before.start); + while t < after.end().min(before.end()) { + assert!((before.source_time_at(t) - after.source_time_at(t)).0.abs() <= 1, "{}: media moved at {t:?}", before.name); + t += Tick(filmcraft_time::TICKS_PER_SECOND / 10); + } + } + + #[test] + fn trimming_the_music_keeps_its_waveform_in_place() { + let (mut app, seq) = demo(); + let music = seq.audio_tracks.iter().flat_map(|t| &t.items).find(|i| i.name.contains("Ambient")).unwrap().clone(); + for (edge, delta) in [(filmcraft_edit::Edge::In, 5), (filmcraft_edit::Edge::Out, -5), (filmcraft_edit::Edge::In, -1)] { + let delta = Tick(delta * filmcraft_time::TICKS_PER_SECOND); + app.tl.drag = Some(Drag::Trim { clip: music.id, edge, mode: filmcraft_edit::TrimMode::Regular, delta, from: Tick::ZERO }); + let (mut out, mut edited) = (HashMap::new(), HashMap::new()); + preview_drag(&app, &seq, &mut out, &mut edited); + let shown = edited.get(&music.id).expect("the trimmed clip is drawn from its edited item"); + // where the preview places it is unchanged + let want = match edge { + filmcraft_edit::Edge::In => (music.start + delta, music.duration - delta, None), + filmcraft_edit::Edge::Out => (music.start, music.duration + delta, None), + }; + assert_eq!(out.get(&music.id).copied(), Some(want)); + assert_eq!((shown.start, shown.duration), (want.0, want.1)); + // the music under every remaining timeline time is the same: the waveform stays put + assert_same_media(&music, shown); + if edge == filmcraft_edit::Edge::In { + assert_eq!(shown.source_in, music.source_in + delta, "5 s into the music, not the music squeezed"); + } + } + } + + #[test] + fn rolling_an_edit_keeps_both_sides_in_place_but_a_stretch_still_squeezes() { + let (mut app, seq) = demo(); + let (left, right) = seq.audio_tracks[0].items.windows(2).find(|w| w[0].end() == w[1].start).map(|w| (w[0].clone(), w[1].clone())).unwrap(); + let delta = Tick(filmcraft_time::TICKS_PER_SECOND / 2); + app.tl.drag = Some(Drag::Roll { left: left.id, right: right.id, delta, from: Tick::ZERO }); + let (mut out, mut edited) = (HashMap::new(), HashMap::new()); + preview_drag(&app, &seq, &mut out, &mut edited); + assert_same_media(&left, &edited[&left.id]); + assert_same_media(&right, &edited[&right.id]); + assert_eq!(edited[&right.id].start, right.start + delta); + // Rate Stretch keeps the same media in a new length: drawn from the original item + app.tl.drag = Some(Drag::Stretch { clip: left.id, edge: filmcraft_edit::Edge::Out, delta, from: Tick::ZERO }); + let (mut out, mut edited) = (HashMap::new(), HashMap::new()); + preview_drag(&app, &seq, &mut out, &mut edited); + assert!(edited.is_empty() && out.contains_key(&left.id)); + } +} + #[cfg(test)] mod waveform_tests { use super::waveform_display_gain; diff --git a/crates/ui-egui/tests/timeline_trim_ui.rs b/crates/ui-egui/tests/timeline_trim_ui.rs index 8ea07cd2..849d6386 100644 --- a/crates/ui-egui/tests/timeline_trim_ui.rs +++ b/crates/ui-egui/tests/timeline_trim_ui.rs @@ -298,3 +298,37 @@ fn dragging_a_caption_edge_inward_trims_it() { assert_eq!(s1, s0, "the caption's start stayed: trimmed, not moved"); assert!(e1 < e0, "the caption's end moved in: {e0} → {e1}"); } + +/// #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 +/// 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] +#[ignore] +fn trim_waveform_screenshots() { + let mut d = Driver::demo(); + let seq = d.app().session.active_sequence().expect("sequence"); + let music = seq.audio_tracks.iter().flat_map(|t| &t.items).find(|i| i.name.contains("Ambient")).expect("music clip").id.0; + let (x0, _, y) = d.edges(music); + // waveform peaks are read on a background thread + for _ in 0..40 { + std::thread::sleep(std::time::Duration::from_millis(50)); + d.frames(1); + } + d.screenshot("trim-waveform-before"); + let push = |d: &mut Driver, e: egui::Event| d.harness.input_mut().events.push(e); + let to = pos2(x0 + 160.0, y); + push(&mut d, egui::Event::PointerMoved(pos2(x0, y))); + d.frames(1); + push(&mut d, egui::Event::PointerButton { pos: pos2(x0, y), button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + d.frames(1); + for i in 1..=20 { + push(&mut d, egui::Event::PointerMoved(pos2(x0 + 160.0 * i as f32 / 20.0, y))); + d.frames(1); + } + d.screenshot("trim-waveform-dragging"); + push(&mut d, egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + d.frames(2); + d.screenshot("trim-waveform-released"); +} From 06b835d5a841fde69f484d8fab4f3ea2caa8e447 Mon Sep 17 00:00:00 2001 From: ReReddo <249398616+ReReddo@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:36:41 +0300 Subject: [PATCH 019/144] timeline, project panel: paste images and copied files from the system clipboard (#611) Ctrl+V with a screenshot or an image copied in another app, or with files copied in Explorer / Finder, now pastes them. In the Timeline they are imported and placed at the playhead, end to end, without overwriting anything: each clip goes on the first free track from the targeted one outward (up for video, down for audio), and a track is added when every track is busy there. Ctrl+Shift+V inserts them on the targeted tracks, as Paste Insert does. In the Project panel Ctrl+V imports into the shown bin. One undo step takes the paste back. Copying clips already puts their names on the system clipboard as text, so media on the clipboard was copied after them; without media, Paste pastes the copied clips as before. - engine: edit.pasteMedia {paths, insert, place, bin}; paste_media:: save_pasted_image writes "Pasted Image .png" where voice-over takes and narrations go (voiceover::media_dir: Scratch Disks > Captured Audio and Video, else next to the project, else the data directory), trying a bounded number of names as voiceover::unique_wav_path does. timeline.place's default length moves to commands::full_duration so both share it. - ui-egui: HostHooks::clipboard_media reads the clipboard (ui-egui stays OS-independent); menus::invoke turns edit.paste / edit.pasteInsert into a media paste when the Timeline or Project panel has focus. egui-winit drops the Ctrl+V press when the clipboard holds no text but passes the release, so a V release without its press puts the press back. That also makes Ctrl+V paste copied clips when the system clipboard is empty. - filmcraft (desktop): the hook, with arboard (already in the tree through egui): copied files first, else an image. Not covered: the web build, whose browser clipboard works differently. Tests: paste_media_tests (5: image above the footage with nothing overwritten and one undo step, tracks added when every track is busy, Paste Insert, import only, saved image names) and clipboard_paste_ui (4: an image pasted from a key release with no text, copied files and Paste Insert, the Project panel imports, copied clips still paste and a plain V pastes nothing). --- Cargo.lock | 1 + apps/filmcraft/Cargo.toml | 2 + apps/filmcraft/src/main.rs | 13 ++ crates/engine/src/commands.rs | 26 ++- crates/engine/src/lib.rs | 3 + crates/engine/src/paste_media.rs | 123 ++++++++++++ crates/engine/src/paste_media_tests.rs | 173 +++++++++++++++++ crates/ui-egui/src/lib.rs | 38 ++++ crates/ui-egui/src/menus.rs | 36 ++++ crates/ui-egui/tests/clipboard_paste_ui.rs | 206 +++++++++++++++++++++ 10 files changed, 611 insertions(+), 10 deletions(-) create mode 100644 crates/engine/src/paste_media.rs create mode 100644 crates/engine/src/paste_media_tests.rs create mode 100644 crates/ui-egui/tests/clipboard_paste_ui.rs diff --git a/Cargo.lock b/Cargo.lock index 1839bb29..4d882172 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1667,6 +1667,7 @@ dependencies = [ name = "filmcraft" version = "0.5.0" dependencies = [ + "arboard", "chrono", "cpal", "eframe", diff --git a/apps/filmcraft/Cargo.toml b/apps/filmcraft/Cargo.toml index 57877b4c..150bc67c 100644 --- a/apps/filmcraft/Cargo.toml +++ b/apps/filmcraft/Cargo.toml @@ -27,6 +27,8 @@ serde_json = { workspace = true } log = { workspace = true } rfd = { workspace = true } cpal = "0.16" +# Files and images on the system clipboard for Paste (#611); egui already uses it for text. +arboard = "3" chrono = { version = "0.4", default-features = false, features = ["clock"] } sys-locale = "0.3" diff --git a/apps/filmcraft/src/main.rs b/apps/filmcraft/src/main.rs index c8ed56e0..ea3f6d68 100644 --- a/apps/filmcraft/src/main.rs +++ b/apps/filmcraft/src/main.rs @@ -236,6 +236,7 @@ fn main() -> eframe::Result { app.hooks.pick_folder_at = Some(Box::new(|dir: &str| rfd::FileDialog::new().set_directory(dir).pick_folder().map(|p| p.to_string_lossy().into_owned()))); app.hooks.open_path = Some(Box::new(open_path)); + app.hooks.clipboard_media = Some(Box::new(clipboard_media)); // Settings ▸ General ▸ Interface Language ▸ System Language (#218). app.hooks.cursor_screen_position = Some(Box::new(filmcraft_platform::cursor::cursor_screen_position)); app.hooks.system_languages = Some(Box::new(|| sys_locale::get_locales().collect())); @@ -282,6 +283,18 @@ fn main() -> eframe::Result { started } +/// Files copied in the file manager, else an image, on the system clipboard (Paste, #611). +fn clipboard_media() -> Option { + let mut clipboard = arboard::Clipboard::new().ok()?; + if let Ok(files) = clipboard.get().file_list() + && !files.is_empty() + { + return Some(filmcraft_ui_egui::ClipboardMedia::Files(files.iter().map(|p| p.to_string_lossy().into_owned()).collect())); + } + let image = clipboard.get_image().ok()?; + Some(filmcraft_ui_egui::ClipboardMedia::Image { width: image.width, height: image.height, rgba: image.bytes.into_owned() }) +} + /// Open a file in its default application, or reveal it in the file manager (Edit Original, /// Reveal Log Files). fn open_path(path: &str, reveal: bool) -> Result<(), String> { diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 0b3a9f8a..16ac177f 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -688,6 +688,20 @@ pub(crate) fn place_item( }) } +/// How long an item runs on the Timeline without In/Out marks: its duration, or for a still (and a +/// generator without a length) Settings ▸ Timeline ▸ Still Image Default Duration. +pub(crate) fn full_duration(s: &Session, pi: &filmcraft_project::ProjectItem) -> Tick { + match &pi.kind { + ItemKind::Media(m) + if matches!(m.info.kind, filmcraft_media::MediaKind::Still) + || (matches!(m.info.kind, filmcraft_media::MediaKind::Synthetic) && m.info.duration.0 <= 0) => + { + s.prefs.timeline.still_duration(s.sequence_rate()) + } + _ => pi.duration(), + } +} + pub(crate) fn source_range(s: &Session) -> Option<(ItemId, TimeRange)> { let item = s.state.source_item?; let pi = s.project.item(item)?; @@ -2115,16 +2129,7 @@ fn build() -> Vec { if p.get("audio").is_some() && audio && pi.has_audio() && a.is_none() { return Err(bad("timeline.place", "no audio destination: add or enable an audio track")); } - let full = match &pi.kind { - // Settings ▸ Timeline ▸ Still Image Default Duration - ItemKind::Media(m) - if matches!(m.info.kind, filmcraft_media::MediaKind::Still) - || (matches!(m.info.kind, filmcraft_media::MediaKind::Synthetic) && m.info.duration.0 <= 0) => - { - s.prefs.timeline.still_duration(s.sequence_rate()) - } - _ => pi.duration(), - }; + let full = full_duration(s, pi); let (mi, mo) = match &pi.kind { ItemKind::Media(m) => (m.mark_in, m.mark_out.map(|o| o + pi.frame_rate().frame_duration())), ItemKind::Subclip { range, .. } => (Some(range.start), Some(range.end())), @@ -2834,6 +2839,7 @@ fn build() -> Vec { v.extend(crate::keyboard::commands()); v.extend(crate::project_panel::commands()); v.extend(crate::media_browser::commands()); + v.extend(crate::paste_media::commands()); // Edit ▸ Label ▸ , Paste Attributes, subclips, Video / Audio Options, Replace With Clip… // and their menu order crate::clip_ops::apply_layout(&mut v); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index eea55b32..fe6f94be 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -31,6 +31,7 @@ pub mod mixer; pub mod multicam; pub mod narration; pub mod panels; +pub mod paste_media; pub mod perf; pub mod presets; pub mod previews; @@ -1088,6 +1089,8 @@ mod panels_tests; #[cfg(test)] mod par_tests; #[cfg(test)] +mod paste_media_tests; +#[cfg(test)] mod presets_tests; #[cfg(test)] mod previews_tests; diff --git a/crates/engine/src/paste_media.rs b/crates/engine/src/paste_media.rs new file mode 100644 index 00000000..10ff7eed --- /dev/null +++ b/crates/engine/src/paste_media.rs @@ -0,0 +1,123 @@ +//! Paste media from the system clipboard (#611). The desktop app reads the clipboard: copied files +//! paste by path, and an image is first saved by [`save_pasted_image`] as `Pasted Image .png` +//! where voice-over takes and narrations go (Scratch Disks ▸ Captured Audio and Video, else next +//! to the project). +//! +//! `edit.pasteMedia` imports the files and puts them on the Timeline at the playhead, end to end, +//! without overwriting anything: each clip goes on the first free track from the targeted one +//! outward (up for video, down for audio), and a track is added when every track is busy there. +//! With `insert` they are inserted on the targeted tracks instead, as Paste Insert does. + +use filmcraft_project::{BinId, ClipId, ItemId, TrackId, TrackKind}; +use filmcraft_time::Tick; +use serde_json::{Value, json}; + +use crate::commands::{CommandSpec, always, bad, bool_p, u64_p}; +use crate::{EngineError, Result, Session}; + +/// Write a pasted image (PNG bytes) to a new `Pasted Image .png`; returns its path. +pub fn save_pasted_image(s: &mut Session, png: &[u8]) -> Result { + let dir = crate::voiceover::media_dir(s, &Value::Null, "Pasted Images"); + let names: std::collections::HashSet = s.project.items.values().map(|i| i.name.clone()).collect(); + // bounded like voiceover::unique_wav_path: `file_size` is a filesystem call per try + let path = (1..=99_999u32) + .map(|k| format!("Pasted Image {k}.png")) + .map(|file| (std::path::Path::new(&dir).join(&file).to_string_lossy().into_owned(), file)) + .find(|(path, file)| !names.contains(file) && s.services.file_size(path).is_err()) + .map(|(path, _)| path) + .ok_or_else(|| EngineError::Other(format!("{dir}: no free file name for \"Pasted Image .png\"")))?; + if !cfg!(target_arch = "wasm32") { + std::fs::create_dir_all(&dir).map_err(|e| EngineError::Other(format!("{dir}: {e}")))?; + } + s.services.write_file(&path, png).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + Ok(path) +} + +fn paste_media(s: &mut Session, p: &Value) -> Result { + let cmd = "edit.pasteMedia"; + let paths: Vec = + p.get("paths").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default(); + if paths.is_empty() { + return Err(bad(cmd, "need `paths`")); + } + let place = bool_p(p, "place").unwrap_or(true); + if place && s.active_sequence().is_none() { + return Err(EngineError::NoSequence); + } + let insert = bool_p(p, "insert").unwrap_or(false); + let n0 = s.history.undo.len(); + let r = import_and_place(s, &paths, u64_p(p, "bin").map(BinId), place, insert); + crate::clip_ops::collapse_history(s, n0, if insert { "Paste Insert" } else { "Paste" }); + r +} + +fn import_and_place(s: &mut Session, paths: &[String], bin: Option, place: bool, insert: bool) -> Result { + let mut import = json!({"paths": paths}); + if let Some(b) = bin { + import["bin"] = json!(b.0); + } + let imported = s.execute("file.import", import)?; + let items: Vec = imported["items"].as_array().map(|a| a.iter().filter_map(Value::as_u64).map(ItemId).collect()).unwrap_or_default(); + if !place { + return Ok(json!({"items": items.iter().map(|i| i.0).collect::>(), "clips": []})); + } + let mut at = s.playhead(); + let mut clips: Vec = Vec::new(); + for item in &items { + let Some(pi) = s.project.item(*item) else { continue }; + let (dur, video, audio) = (crate::commands::full_duration(s, pi), pi.has_video(), pi.has_audio()); + let mut params = json!({"item": item.0, "time": at.0, "insert": insert}); + if !insert { + if video { + params["track"] = json!(free_track(s, TrackKind::Video, at, at + dur)?.0); + } + if audio { + params["audioTrack"] = json!(free_track(s, TrackKind::Audio, at, at + dur)?.0); + } + } + let placed = s.execute("timeline.place", params)?; + let ids: Vec = placed["clips"].as_array().map(|a| a.iter().filter_map(Value::as_u64).map(ClipId).collect()).unwrap_or_default(); + let seq = s.active_sequence().ok_or(EngineError::NoSequence)?; + at = ids.iter().filter_map(|c| seq.find_item(*c)).map(|(_, it)| it.end()).max().unwrap_or(at + dur); + clips.extend(ids); + } + // the playhead goes to the end of what was pasted, as after Paste + s.set_playhead(at); + s.state.selection = clips.clone(); + Ok(json!({"items": items.iter().map(|i| i.0).collect::>(), "clips": clips.iter().map(|c| c.0).collect::>()})) +} + +/// The first unlocked track of `kind` from the targeted one outward with nothing in `start..end`. +/// When every track is busy there, a new one is added (on top for video, at the bottom for audio). +fn free_track(s: &mut Session, kind: TrackKind, start: Tick, end: Tick) -> Result { + let seq = s.active_sequence().ok_or(EngineError::NoSequence)?; + let tg = s.targeting(); + let (tracks, dest) = match kind { + TrackKind::Video => (&seq.video_tracks, tg.video_dest), + TrackKind::Audio => (&seq.audio_tracks, tg.audio_dest), + }; + let from = dest.and_then(|d| tracks.iter().position(|t| t.id == d)).unwrap_or(0); + let free = tracks.iter().skip(from).find(|t| !t.locked && !t.items.iter().any(|i| i.start < end && i.end() > start)).map(|t| t.id); + if let Some(t) = free { + return Ok(t); + } + let (params, key) = match kind { + TrackKind::Video => (json!({"video": 1}), "video"), + TrackKind::Audio => (json!({"video": 0, "audio": 1}), "audio"), + }; + let added = s.execute("sequence.addTracks", params)?; + added[key][0].as_u64().map(TrackId).ok_or_else(|| EngineError::Other("no track was added".into())) +} + +pub(crate) fn commands() -> Vec { + vec![CommandSpec { + id: "edit.pasteMedia", + label: "Paste Media", + menu: &[], + shortcut: None, + params: r#"{"paths":[str],"insert":bool?,"place":bool=true,"bin":binId?}"#, + enabled: always, + run: paste_media, + journal: true, + }] +} diff --git a/crates/engine/src/paste_media_tests.rs b/crates/engine/src/paste_media_tests.rs new file mode 100644 index 00000000..6ca2dc2b --- /dev/null +++ b/crates/engine/src/paste_media_tests.rs @@ -0,0 +1,173 @@ +//! Pasting files and images from the system clipboard (#611). + +use std::path::Path; + +use filmcraft_project::{BinEntry, BinId, ClipId, ItemId, TrackKind}; +use filmcraft_time::Tick; +use serde_json::{Value, json}; + +use crate::Session; +use crate::media_test_util::tmp_dir; + +fn demo() -> Session { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + s +} + +fn write_png(path: &Path) -> String { + image::RgbaImage::from_pixel(64, 36, image::Rgba([40, 120, 200, 255])).save(path).unwrap(); + path.to_string_lossy().into_owned() +} + +fn write_wav(path: &Path, seconds: f32) -> String { + std::fs::write(path, crate::voiceover::write_wav_f32(&vec![0.0; (48_000.0 * seconds) as usize], 48_000)).unwrap(); + path.to_string_lossy().into_owned() +} + +/// Every clip of the active sequence: (track kind, track index, clip id, start, end). +fn clips(s: &Session) -> Vec<(TrackKind, usize, u64, Tick, Tick)> { + let q = s.active_sequence().unwrap(); + let mut v = Vec::new(); + for (kind, tracks) in [(TrackKind::Video, &q.video_tracks), (TrackKind::Audio, &q.audio_tracks)] { + for (i, t) in tracks.iter().enumerate() { + v.extend(t.items.iter().map(|c| (kind, i, c.id.0, c.start, c.end()))); + } + } + v +} + +fn pasted(r: &Value) -> Vec { + r["clips"].as_array().unwrap().iter().map(|c| c.as_u64().unwrap()).collect() +} + +/// An image pasted over footage goes on the first video track that is free there, from the +/// targeted one up; every clip already on the Timeline stays as it was. One undo step takes the +/// clip and the imported item back; the playhead moves to its end, as after Paste. +#[test] +fn a_pasted_image_sits_above_the_footage_without_overwriting_it() { + let dir = tmp_dir("paste-media-image"); + let mut s = demo(); + let png = write_png(&dir.join("shot.png")); + let at = s.playhead(); + let (before, items, undo) = (clips(&s), s.project.items.len(), s.history.undo.len()); + assert!(before.iter().any(|c| c.0 == TrackKind::Video && c.1 == 0 && c.3 <= at && c.4 > at), "the playhead is over a V1 clip"); + let r = s.execute("edit.pasteMedia", json!({"paths": [png]})).unwrap(); + let ids = pasted(&r); + assert_eq!(ids.len(), 1, "a still has picture only"); + let after = clips(&s); + for c in &before { + assert!(after.contains(c), "{c:?} is untouched"); + } + let new = *after.iter().find(|c| c.2 == ids[0]).unwrap(); + assert_eq!((new.0, new.3), (TrackKind::Video, at)); + assert!(new.1 > 0, "not on V1, where the footage is"); + assert!(!before.iter().any(|c| c.0 == TrackKind::Video && c.1 == new.1 && c.3 < new.4 && c.4 > new.3), "on a track that was free there"); + assert!(before.iter().any(|c| c.0 == TrackKind::Video && c.1 < new.1 && c.3 < new.4 && c.4 > new.3), "so it sits above a clip"); + assert_eq!(new.4 - new.3, s.prefs.timeline.still_duration(s.sequence_rate()), "for the still image default duration"); + assert_eq!(s.playhead(), new.4); + assert_eq!(s.state.selection, vec![ClipId(ids[0])]); + assert_eq!(s.history.undo.len(), undo + 1); + assert_eq!(s.undo().as_deref(), Some("Paste")); + assert_eq!((clips(&s), s.project.items.len()), (before, items), "undo takes back the clip and the import"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// With every video track busy at the playhead a new one is added on top; undo removes it too. +/// Audio goes on the first free audio track from the targeted one down, or a new one at the bottom. +#[test] +fn a_track_is_added_when_every_track_is_busy() { + let dir = tmp_dir("paste-media-tracks"); + let mut s = demo(); + let png = write_png(&dir.join("a.png")); + let wav = write_wav(&dir.join("b.wav"), 2.0); + let at = s.playhead(); + // fill every track at the playhead + for _ in 0..8 { + let q = s.active_sequence().unwrap(); + let busy = |ts: &[filmcraft_project::Track]| ts.iter().all(|t| t.items.iter().any(|c| c.start <= at && c.end() > at + Tick::from_seconds_f64(5.0))); + if busy(&q.video_tracks) && busy(&q.audio_tracks) { + break; + } + s.set_playhead(at); + s.execute("edit.pasteMedia", json!({"paths": [png.clone(), wav.clone()]})).unwrap(); + } + let (nv, na) = { + let q = s.active_sequence().unwrap(); + (q.video_tracks.len(), q.audio_tracks.len()) + }; + let before = clips(&s); + s.set_playhead(at); + let r = s.execute("edit.pasteMedia", json!({"paths": [png, wav]})).unwrap(); + let ids = pasted(&r); + let q = s.active_sequence().unwrap(); + assert_eq!((q.video_tracks.len(), q.audio_tracks.len()), (nv + 1, na + 1), "one new track of each kind"); + let after = clips(&s); + let image = after.iter().find(|c| c.2 == ids[0]).unwrap(); + assert_eq!((image.0, image.1), (TrackKind::Video, nv), "the image on the new top track"); + let sound = after.iter().find(|c| c.2 == ids[1]).unwrap(); + assert_eq!((sound.0, sound.1, sound.3), (TrackKind::Audio, na, image.4), "the sound on the new bottom track, after the image"); + for c in &before { + assert!(after.contains(c), "{c:?} is untouched"); + } + s.undo(); + let q = s.active_sequence().unwrap(); + assert_eq!((q.video_tracks.len(), q.audio_tracks.len()), (nv, na), "undo removes the tracks it added"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// `insert` is Paste Insert: on the targeted video track, pushing later clips right. +#[test] +fn paste_insert_inserts_on_the_targeted_track() { + let dir = tmp_dir("paste-media-insert"); + let mut s = demo(); + for t in ["V2", "V3", "A2", "A3"] { + let _ = s.execute("timeline.setTrack", json!({"track": t, "syncLock": false})); + } + let png = write_png(&dir.join("i.png")); + let at = s.playhead(); + let last = s.active_sequence().unwrap().video_tracks[0].items.iter().map(|c| c.end()).max().unwrap(); + let r = s.execute("edit.pasteMedia", json!({"paths": [png], "insert": true})).unwrap(); + let new = *clips(&s).iter().find(|c| c.2 == pasted(&r)[0]).unwrap(); + let still = s.prefs.timeline.still_duration(s.sequence_rate()); + assert_eq!((new.0, new.1, new.3), (TrackKind::Video, 0, at), "on the targeted track, V1"); + assert_eq!(s.active_sequence().unwrap().video_tracks[0].items.iter().map(|c| c.end()).max().unwrap(), last + still, "later clips moved right"); + assert_eq!(s.undo().as_deref(), Some("Paste Insert")); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Without `place` (the Project panel) the files are only imported, into `bin`. +#[test] +fn without_place_files_are_only_imported() { + let dir = tmp_dir("paste-media-import"); + let mut s = demo(); + let png = write_png(&dir.join("p.png")); + let bin = s.execute("file.newBin", json!({"name": "Pasted"})).unwrap()["bin"].as_u64().unwrap(); + let before = clips(&s); + let r = s.execute("edit.pasteMedia", json!({"paths": [png], "place": false, "bin": bin})).unwrap(); + assert_eq!(clips(&s), before); + let item = ItemId(r["items"][0].as_u64().unwrap()); + let pasted_bin = s.project.root.find_bin(BinId(bin)).unwrap(); + assert!(pasted_bin.children.iter().any(|e| matches!(e, BinEntry::Item(i) if *i == item)), "imported into the bin"); + assert!(s.execute("edit.pasteMedia", json!({"paths": []})).is_err(), "nothing to paste"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A pasted image is written as `Pasted Image .png` next to the saved project, with the first +/// number no file or project item uses. +#[test] +fn pasted_images_are_saved_next_to_the_project() { + let dir = tmp_dir("paste-media-save"); + let mut s = demo(); + s.path = Some(dir.join("film.fcproj").to_string_lossy().into_owned()); + let mut png = Vec::new(); + image::RgbaImage::from_pixel(8, 8, image::Rgba([0, 0, 0, 255])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let first = crate::paste_media::save_pasted_image(&mut s, &png).unwrap(); + assert_eq!(Path::new(&first), dir.join("Pasted Image 1.png")); + assert_eq!(std::fs::read(&first).unwrap(), png); + let second = crate::paste_media::save_pasted_image(&mut s, &png).unwrap(); + assert_eq!(Path::new(&second), dir.join("Pasted Image 2.png")); + s.execute("edit.pasteMedia", json!({"paths": [second]})).unwrap(); + assert!(s.project.items.values().any(|i| i.name == "Pasted Image 2.png")); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 857f42a5..786a1aa7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -143,6 +143,17 @@ pub struct HostHooks { /// whose Wayland compositor sends no theme to winit). Without it, or without an answer, Auto /// uses `egui::Context::system_theme`. pub system_theme: Option, + /// Files or an image on the system clipboard, for Paste in the Timeline and the Project panel + /// (#611). Without it Paste only pastes clips copied in FilmCraft. + pub clipboard_media: Option Option>>, +} + +/// What [`HostHooks::clipboard_media`] found on the system clipboard. +pub enum ClipboardMedia { + /// Files copied in the file manager. + Files(Vec), + /// An image (a screenshot, an image copied in a browser): 8-bit RGBA, row by row. + Image { width: usize, height: usize, rgba: Vec }, } /// Reads the system appearance (Settings ▸ Appearance ▸ Appearance Mode ▸ Sync with system). @@ -302,6 +313,8 @@ pub struct FilmcraftApp { pub tl: panels::timeline::TlState, /// Commands from outside the UI (native menu bar), invoked on the UI thread. pub command_inbox: Option>, + /// A V key press (or a Ctrl+V paste) reached the app and its release hasn't yet. + v_down: bool, /// GPU compositor (when running on wgpu): device state + compositor + the egui texture it feeds. pub gpu: Option, /// Preview render job being watched (job id, where to start playing when it completes). @@ -489,6 +502,7 @@ impl FilmcraftApp { toast: None, tl: Default::default(), command_inbox: None, + v_down: false, gpu: None, watched_render: None, applied_prefs: None, @@ -1290,6 +1304,30 @@ impl FilmcraftApp { let mut fire = Vec::new(); ctx.input_mut(|i| { let modifiers = i.modifiers; + // With no text on the system clipboard, egui-winit drops the Ctrl+V press but still + // passes its release: put the press back, so Paste can paste files or an image (#611). + let mut swallowed = None; + for e in &i.events { + match e { + egui::Event::Key { key: egui::Key::V, pressed, modifiers: m, .. } => { + if !pressed && !self.v_down { + swallowed = Some(*m); + } + self.v_down = *pressed; + } + egui::Event::Paste(_) if modifiers.command => self.v_down = true, + _ => {} + } + } + if let Some(m) = swallowed { + i.events.push(egui::Event::Key { + key: egui::Key::V, + physical_key: Some(egui::Key::V), + pressed: true, + repeat: false, + modifiers: m | egui::Modifiers::COMMAND, + }); + } clipboard_events_as_keys(&mut i.events, modifiers); let panel = self.bindings.iter().filter(|b| b.3.as_deref() == Some(focused)); let app_wide = self.bindings.iter().filter(|b| b.3.is_none()); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9dff12d3..7ba3df1d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -490,6 +490,16 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: app.dialog = Some(crate::Dialog::RevertConfirm); return Ok(json!({"dialog": "revert"})); } + // Files or an image on the system clipboard paste into the Timeline, or import into the + // Project panel (#611). Copying clips puts their names there as text, so media on the + // clipboard was copied after them; without media, Paste pastes the copied clips. + "edit.paste" | "edit.pasteInsert" + if params.as_object().is_none_or(|m| m.is_empty()) && matches!(app.ui.focused, PanelKind::Timeline | PanelKind::Project) => + { + if let Some(r) = paste_clipboard_media(app, id == "edit.pasteInsert") { + return r; + } + } "file.recover" if params.as_object().is_none_or(|m| m.is_empty()) => { if app.session.recovery_candidates().is_empty() { return Err("there are no unsaved changes to recover".into()); @@ -545,6 +555,32 @@ fn put_clip_names_on_system_clipboard(app: &FilmcraftApp, ctx: &egui::Context) { ctx.copy_text(if text.trim().is_empty() { format!("{} clips", names.len()) } else { text }); } +/// Paste the files or the image on the system clipboard: placed on the Timeline when it has focus, +/// imported into the shown bin in the Project panel. None when the clipboard holds neither. +fn paste_clipboard_media(app: &mut FilmcraftApp, insert: bool) -> Option> { + let media = (app.hooks.clipboard_media.as_mut()?)()?; + let paths = match media { + crate::ClipboardMedia::Files(paths) => paths, + crate::ClipboardMedia::Image { width, height, rgba } => match encode_png(width, height, rgba) + .and_then(|png| filmcraft_engine::paste_media::save_pasted_image(&mut app.session, &png).map_err(|e| e.to_string())) + { + Ok(path) => vec![path], + Err(e) => return Some(Err(e)), + }, + }; + let params = json!({"paths": paths, "insert": insert, "place": app.ui.focused == PanelKind::Timeline, "bin": app.import_bin().0}); + Some(app.session.execute("edit.pasteMedia", params).map_err(|e| e.to_string())) +} + +/// A compressed PNG of a clipboard image; an error when its size and pixels disagree. +fn encode_png(width: usize, height: usize, rgba: Vec) -> Result, String> { + let (w, h) = (u32::try_from(width).map_err(|e| e.to_string())?, u32::try_from(height).map_err(|e| e.to_string())?); + let img = image::RgbaImage::from_raw(w, h, rgba).ok_or("the image on the clipboard is malformed")?; + let mut png = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).map_err(|e| e.to_string())?; + Ok(png) +} + /// A menu tree entry for display / `ui.menu.list`. #[derive(Clone, Debug, serde::Serialize)] pub struct MenuItem { diff --git a/crates/ui-egui/tests/clipboard_paste_ui.rs b/crates/ui-egui/tests/clipboard_paste_ui.rs new file mode 100644 index 00000000..86beeb73 --- /dev/null +++ b/crates/ui-egui/tests/clipboard_paste_ui.rs @@ -0,0 +1,206 @@ +//! Paste files and images from the system clipboard (#611): the real `FilmcraftApp` under +//! `egui_kittest`, with a stand-in for the desktop app's clipboard hook. + +use std::cell::Cell; +use std::rc::Rc; +use std::sync::mpsc::{Sender, channel}; + +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_ui_egui::control::ControlRequest; +use filmcraft_ui_egui::{ClipboardMedia, FilmcraftApp}; +use serde_json::{Value, json}; + +struct Driver { + harness: Harness<'static, FilmcraftApp>, + tx: Sender, + dir: std::path::PathBuf, +} + +impl Driver { + /// The demo project saved (in name) in a fresh folder, so pasted images are written there. + fn demo(name: &str) -> Self { + let dir = std::env::temp_dir().join(format!("filmcraft-clipboard-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).expect("demo project"); + session.path = Some(dir.join("film.fcproj").to_string_lossy().into_owned()); + 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_max_steps(10_000).build_eframe(move |_cc| app); + let mut d = Driver { harness, tx, dir }; + 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 app(&mut self) -> &mut FilmcraftApp { + self.harness.state_mut() + } + + /// The clipboard hook hands out `media` (a copy each time); returns how often it was asked. + fn clipboard(&mut self, media: Option ClipboardMedia>) -> Rc> { + let asked = Rc::new(Cell::new(0)); + let count = asked.clone(); + self.app().hooks.clipboard_media = Some(Box::new(move || { + count.set(count.get() + 1); + media.map(|m| m()) + })); + asked + } + + /// Ctrl+V as egui-winit delivers it when the system clipboard holds no text: the press is + /// dropped, only the release arrives. + fn ctrl_v_without_text(&mut self, modifiers: egui::Modifiers) { + let key = |pressed| egui::Event::Key { key: egui::Key::V, physical_key: Some(egui::Key::V), pressed, repeat: false, modifiers }; + self.harness.input_mut().events.extend([egui::Event::ModifiersChanged(modifiers), key(false)]); + self.frames(1); + self.harness.input_mut().events.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + self.frames(2); + } + + /// Ctrl+V with text on the clipboard: egui-winit sends `Event::Paste`, then the release. + fn ctrl_v_with_text(&mut self) { + let release = + egui::Event::Key { key: egui::Key::V, physical_key: Some(egui::Key::V), pressed: false, repeat: false, modifiers: egui::Modifiers::COMMAND }; + self.harness.input_mut().events.extend([egui::Event::ModifiersChanged(egui::Modifiers::COMMAND), egui::Event::Paste("text".into()), release]); + self.frames(1); + self.harness.input_mut().events.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + self.frames(2); + } + + /// (track kind, track index, clip id, item name, start, end) of every clip, video first. + fn clips(&mut self) -> Vec<(char, usize, u64, String, i64, i64)> { + let q = self.app().session.active_sequence().unwrap().clone(); + let mut v = Vec::new(); + for (kind, tracks) in [('V', &q.video_tracks), ('A', &q.audio_tracks)] { + for (i, t) in tracks.iter().enumerate() { + v.extend(t.items.iter().map(|c| (kind, i, c.id.0, c.name.clone(), c.start.0, c.end().0))); + } + } + v + } +} + +impl Drop for Driver { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.dir); + } +} + +fn red_image() -> ClipboardMedia { + ClipboardMedia::Image { width: 32, height: 18, rgba: [220, 30, 30, 255].repeat(32 * 18) } +} + +/// Ctrl+V in the Timeline with an image on the clipboard (no text, so only the key release +/// arrives) saves it next to the project and places it at the playhead above the footage, +/// overwriting nothing. One undo takes it back. +#[test] +fn ctrl_v_pastes_an_image_above_the_footage() { + let mut d = Driver::demo("image"); + d.ok("ui.set", json!({"focused": "Timeline"})); + d.clipboard(Some(red_image)); + let before = d.clips(); + let at = d.app().session.playhead().0; + d.ctrl_v_without_text(egui::Modifiers::COMMAND); + let after = d.clips(); + for c in &before { + assert!(after.contains(c), "{c:?} is untouched"); + } + let new: Vec<_> = after.iter().filter(|c| !before.contains(c)).cloned().collect(); + assert_eq!(new.len(), 1, "one clip pasted: {new:?}"); + let (kind, track, _, name, start, end) = new[0].clone(); + assert_eq!((kind, name.as_str(), start), ('V', "Pasted Image 1.png", at)); + assert!(track > 0 && before.iter().any(|c| c.0 == 'V' && c.1 < track && c.4 < end && c.5 > start), "it sits above a clip"); + assert!(d.dir.join("Pasted Image 1.png").is_file(), "saved next to the project"); + d.ok("ui.menu.invoke", json!({"id": "edit.undo"})); + assert_eq!(d.clips(), before); +} + +/// Files copied in the file manager paste the same way, even when the clipboard also holds text +/// (Ctrl+V then arrives as a paste event); Ctrl+Shift+V inserts them on the targeted track. +#[test] +fn copied_files_paste_and_paste_insert_inserts() { + let mut d = Driver::demo("files"); + d.ok("ui.set", json!({"focused": "Timeline"})); + let png = d.dir.join("copied.png"); + image::RgbaImage::from_pixel(16, 9, image::Rgba([0, 200, 0, 255])).save(&png).unwrap(); + let path = png.to_string_lossy().into_owned(); + d.app().hooks.clipboard_media = Some(Box::new(move || Some(ClipboardMedia::Files(vec![path.clone()])))); + let before = d.clips(); + d.ctrl_v_with_text(); + let new: Vec<_> = d.clips().into_iter().filter(|c| !before.contains(c)).collect(); + assert_eq!(new.iter().map(|c| c.3.as_str()).collect::>(), ["copied.png"]); + assert!(new[0].1 > 0, "not over the footage on V1"); + // Paste Insert: on V1, pushing the clips after the playhead right + let v1_end = |d: &mut Driver| d.clips().iter().filter(|c| c.0 == 'V' && c.1 == 0).map(|c| c.5).max().unwrap(); + let end = v1_end(&mut d); + d.ctrl_v_without_text(egui::Modifiers::COMMAND | egui::Modifiers::SHIFT); + assert!(v1_end(&mut d) > end, "Ctrl+Shift+V inserts on V1"); +} + +/// In the Project panel Ctrl+V imports into the shown bin and places nothing. +#[test] +fn ctrl_v_in_the_project_panel_imports() { + let mut d = Driver::demo("project"); + d.ok("ui.set", json!({"focused": "Project"})); + d.clipboard(Some(red_image)); + let (before, items) = (d.clips(), d.app().session.project.items.len()); + d.ctrl_v_without_text(egui::Modifiers::COMMAND); + assert_eq!(d.clips(), before, "nothing on the Timeline"); + assert_eq!(d.app().session.project.items.len(), items + 1); + assert!(d.app().session.project.items.values().any(|i| i.name == "Pasted Image 1.png")); +} + +/// With nothing but clips copied in FilmCraft, Ctrl+V pastes the clips as before, also when the +/// system clipboard holds no text at all. A plain V press and release (the Selection tool) pastes +/// nothing and doesn't read the clipboard. +#[test] +fn without_media_ctrl_v_pastes_copied_clips() { + let mut d = Driver::demo("clips"); + d.ok("ui.set", json!({"focused": "Timeline"})); + let asked = d.clipboard(None); + let first = d.clips().into_iter().find(|c| c.0 == 'V' && c.1 == 0).unwrap(); + d.ok("engine.execute", json!({"command": "timeline.select", "params": {"clips": [first.2]}})); + d.ok("engine.execute", json!({"command": "edit.copy", "params": {}})); + let copies = |d: &mut Driver| d.clips().iter().filter(|c| c.0 == 'V' && c.3 == first.3).count(); + let n = copies(&mut d); + d.ctrl_v_without_text(egui::Modifiers::COMMAND); + assert_eq!(copies(&mut d), n + 1, "the copied clip is pasted"); + assert_eq!(asked.get(), 1); + for pressed in [true, false] { + d.harness.input_mut().events.push(egui::Event::Key { + key: egui::Key::V, + physical_key: Some(egui::Key::V), + pressed, + repeat: false, + modifiers: egui::Modifiers::NONE, + }); + d.frames(1); + } + d.frames(2); + assert_eq!((copies(&mut d), asked.get()), (n + 1, 1), "V alone pastes nothing"); +} From 09165a383ddd4f92d4e6953fbb9a7be8808a84c8 Mon Sep 17 00:00:00 2001 From: Lukas Keller Date: Sat, 10 Oct 2026 13:06:02 +0200 Subject: [PATCH 020/144] i18n: German (de) joins Edit > Language Language::De is persisted as `de` and translated through a new de.tsv catalog that covers every menu label and the native macOS Settings menu, written from the plain meaning of the English labels with informal "du" and infinitive commands. The new app.language.german command checks, switches and restores the language like the others, the native macOS menu maps it back, Settings > General offers Deutsch, and System Language picks German for any de-* locale (de-DE, de-AT, de-CH ...). A new test, built like the Ukrainian one, fails when German misses a core menu label (the Brazilian Portuguese entries, the top-level menus, the macOS Settings menu) or keeps an entry that is no longer a menu label; menu labels added later use English until they are translated. The System Language test no longer uses `de` as an unsupported example. Closes #490 Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- apps/filmcraft/src/native_menu.rs | 2 + crates/engine/src/settings.rs | 7 +- crates/engine/src/settings_tests.rs | 4 +- crates/ui-egui/README.md | 8 +- crates/ui-egui/src/i18n/de.tsv | 385 ++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/mod.rs | 78 +++++- crates/ui-egui/src/menus.rs | 4 + 8 files changed, 475 insertions(+), 15 deletions(-) create mode 100644 crates/ui-egui/src/i18n/de.tsv diff --git a/ROADMAP.md b/ROADMAP.md index bd80f88b..9aef2f7c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -211,4 +211,4 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining ### Interface localization -Edit > Language switches English/Japanese/Spanish/Portuguese (Brazilian)/Ukrainian/Simplified Chinese and persists across restarts through `general.interfaceLanguage` (also available in Settings > General). Until a language is chosen, System Language (the default) follows the operating system's or browser's preferred languages and falls back to English. Commands `app.language.english`, `app.language.japanese`, `app.language.spanish`, `app.language.portuguese`, `app.language.ukrainian` and `app.language.chinese` are reachable through the control channel. Translations live in one catalog per language (`crates/ui-egui/src/i18n/.tsv`); UI code wraps its strings in `tl!("…")` / `tlf!("…{name}", name)`, and names that come from registries (effects and their parameters, settings, commands, panels, workspaces, presets) are translated where they are drawn. **Spanish, Japanese and Simplified Chinese cover the whole interface**: menus, panels, dialogs, the Settings pages, the Effects/Effect Controls registry, the Keyboard Shortcuts dialog and status messages; tests fail when a `tl!` literal, a menu label or a registry name has no Spanish entry (`ja.tsv` has the same entries, but the tests do not require them yet; `zh-cn.tsv` must cover every `tl!` literal and menu label too). Searches match the displayed labels as well as English sources. Brazilian Portuguese and Ukrainian cover the core menus; untranslated labels use English. Ukrainian uses the bundled Cyrillic interface fonts. Engine error messages, the control channel, the CLI and MCP stay in English. Japanese text uses the Japanese fonts from [craft-fonts](https://github.com/storytold/craft-fonts) when FilmCraft is built with them (`CRAFT_FONTS_DIR`; all official releases, including the web build), otherwise a Japanese font already installed on the system; with neither, switching to Japanese is refused with a message. Simplified Chinese (`zh-cn`; System Language picks it for `zh`, `zh-CN`, `zh-SG` and `zh-Hans`, not for Traditional locales) uses the Chinese fallback font the interface always installs (craft-fonts, otherwise an installed system face); with none, switching to it is refused the same way. Existing vertical text support is preserved. +Edit > Language switches English/Japanese/Spanish/Portuguese (Brazilian)/Ukrainian/Simplified Chinese/German and persists across restarts through `general.interfaceLanguage` (also available in Settings > General). Until a language is chosen, System Language (the default) follows the operating system's or browser's preferred languages and falls back to English. Commands `app.language.english`, `app.language.japanese`, `app.language.spanish`, `app.language.portuguese`, `app.language.ukrainian`, `app.language.chinese` and `app.language.german` are reachable through the control channel. Translations live in one catalog per language (`crates/ui-egui/src/i18n/.tsv`); UI code wraps its strings in `tl!("…")` / `tlf!("…{name}", name)`, and names that come from registries (effects and their parameters, settings, commands, panels, workspaces, presets) are translated where they are drawn. **Spanish, Japanese and Simplified Chinese cover the whole interface**: menus, panels, dialogs, the Settings pages, the Effects/Effect Controls registry, the Keyboard Shortcuts dialog and status messages; tests fail when a `tl!` literal, a menu label or a registry name has no Spanish entry (`ja.tsv` has the same entries, but the tests do not require them yet; `zh-cn.tsv` must cover every `tl!` literal and menu label too). Searches match the displayed labels as well as English sources. German covers every menu, including the native macOS Settings menu; menu labels added later use English until they are translated. Brazilian Portuguese and Ukrainian cover the core menus; untranslated labels use English. Ukrainian uses the bundled Cyrillic interface fonts. Engine error messages, the control channel, the CLI and MCP stay in English. Japanese text uses the Japanese fonts from [craft-fonts](https://github.com/storytold/craft-fonts) when FilmCraft is built with them (`CRAFT_FONTS_DIR`; all official releases, including the web build), otherwise a Japanese font already installed on the system; with neither, switching to Japanese is refused with a message. Simplified Chinese (`zh-cn`; System Language picks it for `zh`, `zh-CN`, `zh-SG` and `zh-Hans`, not for Traditional locales) uses the Chinese fallback font the interface always installs (craft-fonts, otherwise an installed system face); with none, switching to it is refused the same way. Existing vertical text support is preserved. diff --git a/apps/filmcraft/src/native_menu.rs b/apps/filmcraft/src/native_menu.rs index 17ff888c..5a08ba97 100644 --- a/apps/filmcraft/src/native_menu.rs +++ b/apps/filmcraft/src/native_menu.rs @@ -103,6 +103,8 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver, Sho filmcraft_ui_egui::i18n::Language::Uk } else if checked("app.language.chinese") { filmcraft_ui_egui::i18n::Language::ZhCn + } else if checked("app.language.german") { + filmcraft_ui_egui::i18n::Language::De } else { filmcraft_ui_egui::i18n::Language::En }; diff --git a/crates/engine/src/settings.rs b/crates/engine/src/settings.rs index 2f020835..865d3758 100644 --- a/crates/engine/src/settings.rs +++ b/crates/engine/src/settings.rs @@ -33,9 +33,9 @@ pub const PREFS_VERSION: u32 = 2; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] pub struct GeneralPrefs { - /// Interface language (persisted independently of projects): `en`, `ja`, `es`, `pt-br`, or - /// `system` (the default: the operating system's preferred language when the interface has it, - /// otherwise English). + /// Interface language (persisted independently of projects): `en`, `ja`, `es`, `pt-br`, `uk`, + /// `zh-cn`, `de`, or `system` (the default: the operating system's preferred language when the + /// interface has it, otherwise English). pub interface_language: String, /// "At Startup": `showHome` (FilmCraft: the demo project), `openMostRecent`, `emptyProject`. pub at_startup: String, @@ -752,6 +752,7 @@ static CATEGORIES: &[Category] = &[ ("pt-br", "Português (Brasil)"), ("uk", "Українська"), ("zh-cn", "简体中文"), + ("de", "Deutsch"), ]), true, ), diff --git a/crates/engine/src/settings_tests.rs b/crates/engine/src/settings_tests.rs index 241f36f6..0d8faa16 100644 --- a/crates/engine/src/settings_tests.rs +++ b/crates/engine/src/settings_tests.rs @@ -29,11 +29,13 @@ fn interface_language_persists_and_rejects_hostile_values() { let mut s = Session { prefs_path: Some(path.clone()), ..Session::default() }; set(&mut s, "general.interfaceLanguage", json!("es")); assert_eq!(Preferences::load(&path).general.interface_language, "es"); - for value in [json!("xx"), json!("es-MX"), json!(null), json!(-1), json!({}), json!("x".repeat(4096))] { + for value in [json!("xx"), json!("es-MX"), json!("de-DE"), json!(null), json!(-1), json!({}), json!("x".repeat(4096))] { assert!(s.execute("prefs.set", json!({"key": "general.interfaceLanguage", "value": value})).is_err()); assert_eq!(s.prefs.general.interface_language, "es"); } assert_eq!(Preferences::load(&path).general.interface_language, "es"); + set(&mut s, "general.interfaceLanguage", json!("de")); + assert_eq!(Preferences::load(&path).general.interface_language, "de"); // back to following the operating system (#218) set(&mut s, "general.interfaceLanguage", json!("system")); assert_eq!(Preferences::load(&path).general.interface_language, "system"); diff --git a/crates/ui-egui/README.md b/crates/ui-egui/README.md index 699a95d4..1c723fea 100644 --- a/crates/ui-egui/README.md +++ b/crates/ui-egui/README.md @@ -17,8 +17,9 @@ the committed span and undo. Set `FILMCRAFT_UI_SNAPSHOT_DIR` to render screensho ## Localisation -Edit > Language offers English, Japanese and Spanish. The language is stored in the engine's -`general.interfaceLanguage` preference, restored on startup, and also available in Settings > General. +Edit > Language offers English, Japanese, Spanish, Brazilian Portuguese, Ukrainian, Simplified +Chinese and German. The language is stored in the engine's `general.interfaceLanguage` preference, +restored on startup, and also available in Settings > General. Its default, System Language (`system`), follows the operating system: the first of the user's preferred languages that the interface has (the host supplies them through `HostHooks::system_languages`: `sys-locale` on the desktop, `navigator.languages` on the web), @@ -34,7 +35,8 @@ original work using ordinary language, without proprietary localisation resource Spanish and Japanese cover menus, panels, dialogs, settings and registry labels. Searches accept both the translated label and its English source, including Unicode capitals. Project content, command ids and preference values retain their original values. Engine errors, CLI and MCP messages remain -English; Brazilian Portuguese currently covers core menus and falls back to English elsewhere. +English. Brazilian Portuguese and Ukrainian currently cover the core menus, German every menu; 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 diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv new file mode 100644 index 00000000..019f4d17 --- /dev/null +++ b/crates/ui-egui/src/i18n/de.tsv @@ -0,0 +1,385 @@ +# German (de) catalog. Same format as `es.tsv`: context source translation. +# +# Clean-room: every entry was written from the plain meaning of the English label using ordinary +# German video-editing vocabulary. No proprietary localisation resource, string table or product UI +# was consulted or copied; add entries the same way. Like the German catalogs of the other Craft +# apps, text addresses the user informally ("du") and commands use the infinitive ("Projekt +# öffnen…"). Product, format and feature names (FilmCraft, ArtCraft, Lumetri, Essential Graphics, +# EDL, AAF…) keep their spelling. This catalog covers every menu, including the native macOS +# Settings menu; anything else is shown in English. +# +# Terms: track = Spur, bin = Ablage, marker = Marker, playhead = Abspielposition, In/Out = In-/Out- +# Punkt, transition = Übergang, frame hold = Standbild, panel = Bedienfeld, workspace = +# Arbeitsbereich, label (colour) = Farbmarkierung, preferences (also the native macOS Settings +# menu) = Voreinstellungen. "Timeline" is not translated. + + AAF… AAF… + About FilmCraft Über FilmCraft + Add Audio Submix Track Audio-Submix-Spur hinzufügen + Add Caption at Playhead Untertitel an Abspielposition hinzufügen + Add Chapter Marker… Kapitelmarker hinzufügen… + Add Edit Schnitt hinzufügen + Add Edit to All Tracks Schnitt auf allen Spuren hinzufügen + Add Flash Cue Marker… Flash-Cue-Marker hinzufügen… + Add Frame Hold Standbild hinzufügen + Add Guide… Hilfslinie hinzufügen… + Add Marker Marker hinzufügen + Add New Caption Track… Neue Untertitelspur hinzufügen… + Add Range Marker Bereichsmarker hinzufügen + Add Range Marker to In and Out Bereichsmarker von In bis Out hinzufügen + Add Tracks… Spuren hinzufügen… + Adjustment Layer… Einstellungsebene… + Align to Selection An Auswahl ausrichten + Align to Video Frame Am Videobild ausrichten + Align to Video Frame as Group Als Gruppe am Videobild ausrichten + All Panels Alle Bedienfelder + Alpha Alpha + Appearance Erscheinungsbild + Appearance… Darstellung… + Apply Audio Transition Audioübergang anwenden + Apply Default Transitions to Selection Standardübergänge auf Auswahl anwenden + Apply Video Transition Videoübergang anwenden + Arrange Anordnen + ArtCraft Website ArtCraft-Website + Assembly Montage + Attach Proxies… Proxys anhängen… + Audio Audio + Audio Channels… Audiokanäle… + Audio Clip Mixer Audioclip-Mischpult + Audio Gain… Audioverstärkung… + Audio Hardware… Audiohardware… + Audio In Audio-In + Audio Meters Audiopegel + Audio Options Audiooptionen + Audio Out Audio-Out + Audio Track Mixer Audiospur-Mischpult + Audio Waveform Audio-Wellenform + Audio… Audio… + Auto Save… Automatisch speichern… + Automate to Sequence… Automatisch in Sequenz einfügen… + Avid Log Exchange… Avid Log Exchange… + Bars and Tone… Farbbalken und Ton… + Bin Ablage + Bin From Selection Ablage aus Auswahl + Black Video… Schwarzbild… + Blue Blau + Bottom Unten + Breakout to Mono In Mono aufteilen + Bring Forward Schrittweise nach vorne + Bring to Front In den Vordergrund + Brown Braun + Captions Untertitel + Captions and Graphics Untertitel und Grafiken + Captions… Untertitel… + Caribbean Karibik + Center Horizontally Horizontal zentrieren + Center Vertically Vertikal zentrieren + Cerulean Himmelblau + Clear Löschen + Clear Guides Hilfslinien löschen + Clear In In-Punkt entfernen + Clear In and Out In- und Out-Punkt entfernen + Clear Markers Marker entfernen + Clear Out Out-Punkt entfernen + Clear Selected Marker Ausgewählten Marker entfernen + Clip Clip + Close Schließen + Close All Other Projects Alle anderen Projekte schließen + Close All Projects Alle Projekte schließen + Close Gap Lücke schließen + Close Project Projekt schließen + Color Farbe + Color Management… Farbmanagement… + Color Matte… Farbfläche… + Color… Farbe… + Comparison View Vergleichsansicht + Composite Video Composite-Video + Consolidate Duplicates Duplikate zusammenführen + Copy Kopieren + Copy Paste Includes Sequence Markers Kopieren und Einfügen mit Sequenzmarkern + Create Captions from Transcript… Untertitel aus Transkript erstellen… + Create Multi-Camera Source Sequence… Mehrkamera-Quellsequenz erstellen… + Create Proxies… Proxys erstellen… + Cut Ausschneiden + Dark Dunkel + Darkest Sehr dunkel + Delete Render Files Renderdateien löschen + Delete Render Files In to Out Renderdateien von In bis Out löschen + Delete Tracks… Spuren löschen… + Delete Transcript Transkript löschen + Demo Footage Demomaterial + Demo Project Demoprojekt + Deselect All Auswahl aufheben + Detach Proxies Proxys trennen + Display Mode Anzeigemodus + Distribute Verteilen + Distribute Horizontally Horizontal verteilen + Distribute Space Horizontally Abstände horizontal verteilen + Distribute Space Vertically Abstände vertikal verteilen + Distribute Vertically Vertikal verteilen + Duplicate Duplizieren + Dynamic Audio Waveforms Dynamische Audio-Wellenformen + EDL… EDL… + Edit Bearbeiten + Edit Offline… Offline bearbeiten… + Edit Original Original bearbeiten + Edit Subclip… Subclip bearbeiten… + Edit Workspaces… Arbeitsbereiche bearbeiten… + Editing Schnitt + Effect Controls Effekteinstellungen + Effects Effekte + Ellipse Ellipse + Enable Aktivieren + Enable Remix Remix aktivieren + Essential Graphics Essential Graphics + Essential Sound Essential Sound + Events Ereignisse + Export Exportieren + Export As Motion Graphics Template… Als Motion-Graphics-Vorlage exportieren… + Export Markers as CSV… Marker als CSV exportieren… + Extract Extrahieren + Extract Audio Audio extrahieren + FCPXML… FCPXML… + Field Options… Halbbildoptionen… + File Datei + File… Datei… + Fill frame Bild füllen + FilmCraft Help… FilmCraft-Hilfe… + FilmCraft on GitHub FilmCraft auf GitHub + FilmCraft on getartcraft.com FilmCraft auf getartcraft.com + Final Cut Pro XML… Final Cut Pro XML… + Find Next Weitersuchen + Find… Suchen… + Fit Einpassen + Fit to frame In Bild einpassen + Flatten Reduzieren + Forest Waldgrün + Frame Blending Frame-Blending + Frame Hold Options… Standbildoptionen… + Frame Sampling Frame-Sampling + From Bin Aus der Ablage + From Source Monitor Aus dem Quellmonitor + From Source Monitor, Match Frame Aus dem Quellmonitor, bildgenau + From file… Aus Datei… + Full Voll + General… Allgemein… + Generate Audio Waveform Audio-Wellenform erzeugen + Get Media File Properties for Mediendatei-Eigenschaften abrufen für + Go to Gap Zur Lücke gehen + Go to In Zum In-Punkt gehen + Go to Next Caption Segment Zum nächsten Untertitelsegment gehen + Go to Next Marker Zum nächsten Marker gehen + Go to Out Zum Out-Punkt gehen + Go to Previous Caption Segment Zum vorherigen Untertitelsegment gehen + Go to Previous Marker Zum vorherigen Marker gehen + Go to Split Zum Split-Punkt gehen + Graphics and Titles Grafiken und Titel + Graphics… Grafiken… + Green Grün + Group Gruppieren + Guide Templates Hilfslinienvorlagen + Help Hilfe + Hide All Caption Tracks Alle Untertitelspuren ausblenden + High Quality Playback Wiedergabe in hoher Qualität + History Verlauf + Import From Importieren aus + Import Image Sequence… Bildsequenz importieren… + Import from Media Browser Aus Medienbrowser importieren + Import… Importieren… + Info Info + Ingest Settings… Ingest-Einstellungen… + Insert Einfügen + Insert Frame Hold Segment Standbildsegment einfügen + Install Motion Graphics Template… Motion-Graphics-Vorlage installieren… + Interpret Footage… Material interpretieren… + Iris Iris + Join the ArtCraft Discord… Dem ArtCraft-Discord beitreten… + Keyboard Shortcuts… Tastenkürzel… + Label Farbmarkierung + Labels… Farbmarkierungen… + Language Sprache + Lavender Lavendel + Learning Lernen + Left Links + Libraries Bibliotheken + Lift Herausheben + Light Hell + Link Verknüpfen + Link Media… Medien verknüpfen… + Linked Selection Verknüpfte Auswahl + Lock Guides Hilfslinien sperren + Lumetri Color Lumetri-Farbe + Lumetri Scopes Lumetri-Scopes + Magenta Magenta + Magnification Vergrößerung + Make Offline… Offline setzen… + Make Subclip… Subclip erstellen… + Make Subsequence Untersequenz erstellen + Manage Guides… Hilfslinien verwalten… + Mango Mango + Mark Clip Clip markieren + Mark In In-Punkt setzen + Mark Out Out-Punkt setzen + Mark Selection Auswahl markieren + Mark Split Split-Punkt setzen + Markers Marker + Match Frame Quellbild finden + Media Analysis & Transcription… Medienanalyse und Transkription… + Media Browser Medienbrowser + Media Cache… Mediencache… + Media… Medien… + Memory… Arbeitsspeicher… + Merge Clips… Clips zusammenführen… + Metadata Metadaten + Modify Ändern + Motion Graphics Template… Motion-Graphics-Vorlage… + Multi-Camera Mehrkamera + Multi-Camera View Mehrkameraansicht + Nest… Verschachteln… + New Neu + New Layer Neue Ebene + New Project Panel Neues Projekt-Bedienfeld + Next Appearance Mode Nächster Darstellungsmodus + Next in Sequence Nächste in der Sequenz + Next in Track Nächste in der Spur + Normalize Mix Track… Mix-Spur normalisieren… + OMF… OMF… + Offline File… Offline-Datei… + Open Project… Projekt öffnen… + OpenTimelineIO… OpenTimelineIO… + Optical Flow Optischer Fluss + Overwrite Überschreiben + Paste Einfügen + Paste Attributes… Attribute einfügen… + Paste Insert Einfügen und verschieben + Paused Resolution Auflösung bei Pause + Playback Resolution Wiedergabeauflösung + Playback… Wiedergabe… + Plugins… Plug-ins… + Polygon Polygon + Preferences Voreinstellungen + Previous in Sequence Vorherige in der Sequenz + Previous in Track Vorherige in der Spur + Program Programm + Progress Fortschritt + Project Projekt + Project Manager… Projektmanager… + Project Settings Projekteinstellungen + Project… Projekt… + Properties Eigenschaften + Proxy Proxy + Purple Lila + Reconnect Full Resolution Media… Medien in voller Auflösung neu verknüpfen… + Recover Unsaved Changes… Nicht gespeicherte Änderungen wiederherstellen… + Rectangle Rechteck + Red Rot + Redo Wiederholen + Reference Monitor Referenzmonitor + Remix Remix + Remix Properties… Remix-Eigenschaften… + Remove Attributes… Attribute entfernen… + Remove Filler Words Füllwörter entfernen + Remove Pauses Pausen entfernen + Remove Unused Nicht verwendete entfernen + Rename… Umbenennen… + Render Audio Audio rendern + Render Effects In to Out Effekte von In bis Out rendern + Render In to Out Von In bis Out rendern + Render Selection Auswahl rendern + Replace Fonts in Projects… Schriften in Projekten ersetzen… + Replace With Clip Durch Clip ersetzen + Report an Issue… Problem melden… + Reset All Parameters Alle Parameter zurücksetzen + Reset Duration Dauer zurücksetzen + Reset to Saved Layout Auf gespeichertes Layout zurücksetzen + Restore Captions from Source Clip Untertitel aus Quellclip wiederherstellen + Reveal Log Files… Protokolldateien anzeigen… + Reverse Match Frame Sequenzbild finden + Revert Zurück zur gespeicherten Version + Revert Remix Remix zurücksetzen + Review Überprüfung + Right Rechts + Ripple Delete Löschen und Lücke schließen + Ripple Sequence Markers Sequenzmarker mitverschieben + Rose Rosa + Safe Margins Sicherheitsränder + Save Speichern + Save All Alle speichern + Save As… Speichern unter… + Save Changes to this Workspace Änderungen an diesem Arbeitsbereich speichern + Save Guides as Template… Hilfslinien als Vorlage speichern… + Save a Copy… Kopie speichern… + Save as New Workspace… Als neuen Arbeitsbereich speichern… + Save as Template… Als Vorlage speichern… + Scale to Frame Size Auf Bildgröße skalieren + Scene Edit Detection… Szenenschnitterkennung… + Scratch Disks… Arbeitslaufwerke… + Search Bin Suchablage + Select Auswählen + Select All Alles auswählen + Select All Matching Alle passenden auswählen + Select Label Group Farbmarkierungsgruppe auswählen + Select Next Graphic Nächste Grafik auswählen + Select Next Layer Nächste Ebene auswählen + Select Previous Graphic Vorherige Grafik auswählen + Select Previous Layer Vorherige Ebene auswählen + Selection Follows Playhead Auswahl folgt Abspielposition + Selection as FilmCraft Project… Auswahl als FilmCraft-Projekt… + Selection… Auswahl… + Send Backward Schrittweise nach hinten + Send to Back In den Hintergrund + Sequence Sequenz + Sequence From Clip Sequenz aus Clip + Sequence Settings… Sequenzeinstellungen… + Sequence… Sequenz… + Settings Voreinstellungen + Show Active Caption Tracks Only Nur aktive Untertitelspuren anzeigen + Show All Caption Tracks Alle Untertitelspuren anzeigen + Show All Marker Colors Alle Markerfarben anzeigen + Show Guides Hilfslinien anzeigen + Show Rulers Lineale anzeigen + Show Through Edits Durchgehende Schnitte anzeigen + Simplify Sequence… Sequenz vereinfachen… + Snap in Program Monitor Im Programmmonitor einrasten + Snap in Timeline In der Timeline einrasten + Source Quelle + Source Settings… Quelleinstellungen… + Speed/Duration… Geschwindigkeit/Dauer… + Synchronize… Synchronisieren… + System Compatibility Report… Systemkompatibilitätsbericht… + Tan Hellbraun + Teal Petrol + Text Text + Time Interpolation Zeitinterpolation + Timecode Timecode + Timecode… Timecode… + Timeline Timeline + Timeline… Timeline… + Toggle Proxies Proxys ein/aus + Tools Werkzeuge + Top Oben + Transcribe Sequence… Sequenz transkribieren… + Transcribe… Transkribieren… + Transcript Transkript + Transparent Video… Transparentes Video… + Trim Edit Schnitt trimmen + Trim… Trimmen… + Undo Rückgängig + Ungroup Gruppierung aufheben + Universal Counting Leader… Universal-Countdown-Vorspann… + Update Metadata… Metadaten aktualisieren… + Upgrade Caption to Graphic Untertitel in Grafik umwandeln + Upgrade to Source Graphic In Quellgrafik umwandeln + Vertical Text Vertikaler Text + Video In Video-In + Video Options Videooptionen + Video Out Video-Out + Video and Audio Waveform Split Video und Audio-Wellenform geteilt + View Ansicht + Violet Violett + Window Fenster + Workspaces Arbeitsbereiche + Yellow Gelb + Zoom In Vergrößern + Zoom Out Verkleinern + Zoom to Sequence Auf Sequenz zoomen diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index fb88bec6..05ce228b 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -42,6 +42,7 @@ pub enum Language { /// Simplified Chinese, persisted as `zh-cn`. #[serde(rename = "zh-cn")] ZhCn, + De, } static JAPANESE: OnceLock = OnceLock::new(); @@ -49,9 +50,10 @@ static SPANISH: OnceLock = OnceLock::new(); static PORTUGUESE: OnceLock = OnceLock::new(); static UKRAINIAN: OnceLock = OnceLock::new(); static CHINESE: OnceLock = OnceLock::new(); +static GERMAN: OnceLock = OnceLock::new(); impl Language { - pub const ALL: [Self; 6] = [Self::En, Self::Ja, Self::Es, Self::PtBr, Self::Uk, Self::ZhCn]; + pub const ALL: [Self; 7] = [Self::En, Self::Ja, Self::Es, Self::PtBr, Self::Uk, Self::ZhCn, Self::De]; pub fn name(self) -> &'static str { match self { @@ -61,6 +63,7 @@ impl Language { Self::PtBr => "Português (Brasil)", Self::Uk => "Українська", Self::ZhCn => "简体中文", + Self::De => "Deutsch", } } @@ -72,17 +75,20 @@ impl Language { "pt-br" => Some(Self::PtBr), "uk" => Some(Self::Uk), "zh-cn" => Some(Self::ZhCn), + "de" => Some(Self::De), _ => None, } } /// Interface Language ▸ System Language (#218): the first of the user's preferred languages /// (BCP 47 or POSIX locale tags such as `es-419`, `pt_BR.UTF-8`, most preferred first) that the - /// interface has, else English. Any Portuguese gets the Brazilian catalog, the only one there is. - /// Chinese gets the Simplified catalog only for Simplified locales (`zh`, `zh-CN`, `zh-SG`, - /// `zh-Hans…`); Traditional ones (`zh-TW`, `zh-HK`, `zh-MO`, `zh-Hant…`) are skipped. + /// interface has, else English. Any Portuguese gets the Brazilian catalog, the only one there is; + /// any German (`de-AT`, `de_CH.UTF-8`…) gets the German one. Chinese gets the Simplified catalog + /// only for Simplified locales (`zh`, `zh-CN`, `zh-SG`, `zh-Hans…`); Traditional ones (`zh-TW`, + /// `zh-HK`, `zh-MO`, `zh-Hant…`) are skipped. pub fn from_locales(tags: &[String]) -> Self { - const PRIMARY: [(&str, Language); 5] = [("en", Language::En), ("ja", Language::Ja), ("es", Language::Es), ("pt", Language::PtBr), ("uk", Language::Uk)]; + const PRIMARY: [(&str, Language); 6] = + [("en", Language::En), ("ja", Language::Ja), ("es", Language::Es), ("pt", Language::PtBr), ("uk", Language::Uk), ("de", Language::De)]; tags.iter() .find_map(|tag| { let mut parts = tag.split(['-', '_', '.', '@']); @@ -108,6 +114,7 @@ impl Language { Self::PtBr => "pt-br", Self::Uk => "uk", Self::ZhCn => "zh-cn", + Self::De => "de", } } @@ -120,6 +127,7 @@ impl Language { Self::PtBr => Some(PORTUGUESE.get_or_init(|| Catalog::parse(include_str!("pt-br.tsv")))), Self::Uk => Some(UKRAINIAN.get_or_init(|| Catalog::parse(include_str!("uk.tsv")))), Self::ZhCn => Some(CHINESE.get_or_init(|| Catalog::parse(include_str!("zh-cn.tsv")))), + Self::De => Some(GERMAN.get_or_init(|| Catalog::parse(include_str!("de.tsv")))), } } @@ -287,6 +295,7 @@ mod tests { ("pt-br", include_str!("pt-br.tsv")), ("uk", include_str!("uk.tsv")), ("zh-cn", include_str!("zh-cn.tsv")), + ("de", include_str!("de.tsv")), ] { let (entries, errors) = catalog::parse_entries(text); assert!(errors.is_empty(), "{code}: {errors:?}"); @@ -316,6 +325,13 @@ mod tests { 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"); for l in Language::ALL { assert_eq!(Language::parse(l.code()), Some(l)); let json = serde_json::to_string(&l).unwrap(); @@ -747,6 +763,26 @@ mod tests { let mut restarted = filmcraft_engine::Session::default(); restarted.prefs = serde_json::from_str(&prefs).unwrap(); assert_eq!(crate::FilmcraftApp::new(restarted).ui.language, Language::Uk); + let result = crate::menus::invoke(&mut app, &ctx, "app.language.german", serde_json::json!({})).unwrap(); + assert_eq!(result, serde_json::json!("de")); + assert_eq!((app.ui.language, current()), (Language::De, Language::De)); + assert_eq!(app.session.prefs.general.interface_language, "de"); + for it in crate::menus::menu_items(&app).iter().filter(|it| it.id.starts_with("app.language.")) { + assert_eq!(it.checked, Some(it.id == "app.language.german"), "{}", it.id); + } + // 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("app.language.spanish"), Some("Español")); + let saved = serde_json::to_string(&app.ui).unwrap(); + assert!(saved.contains("\"language\":\"de\""), "{saved}"); + let restored: crate::state::UiState = serde_json::from_str(&saved).unwrap(); + assert_eq!(restored.language, Language::De); + let prefs = serde_json::to_string(&app.session.prefs).unwrap(); + let mut restarted = filmcraft_engine::Session::default(); + restarted.prefs = serde_json::from_str(&prefs).unwrap(); + assert_eq!(crate::FilmcraftApp::new(restarted).ui.language, Language::De); crate::menus::invoke(&mut app, &ctx, "app.language.english", serde_json::json!({})).unwrap(); assert_eq!(app.ui.language, Language::En); set_current(Language::En); @@ -772,12 +808,18 @@ mod tests { assert_eq!(l(&[tag]), Language::En, "{tag}: Traditional Chinese has no catalog"); } assert_eq!(l(&["zh-TW", "ja-JP"]), Language::Ja); - assert_eq!(l(&["fr-FR", "de", "es-MX", "ja"]), Language::Es, "the first one the interface has"); + for tag in ["de", "DE", "de-DE", "de-AT", "de-CH", "de-LI", "de-LU", "de_DE.UTF-8", "de_AT.UTF-8@euro", "de_CH"] { + assert_eq!(l(&[tag]), Language::De, "{tag}"); + } + assert_eq!(l(&["fr-FR", "it", "es-MX", "ja"]), Language::Es, "the first one the interface has"); + assert_eq!(l(&["fr-CH", "de-CH", "en"]), Language::De); assert_eq!(l(&["en-GB", "es"]), Language::En); - for none in [&[][..], &["fr"], &["C"], &["POSIX"], &[""], &["e"], &["esp"], &["-es"]] { + assert_eq!(l(&["en-US", "de"]), Language::En); + for none in [&[][..], &["fr"], &["C"], &["POSIX"], &[""], &["e"], &["esp"], &["-es"], &["deu"], &["d"], &["-de"]] { assert_eq!(l(none), Language::En, "{none:?}"); } assert_eq!(l(&[&"x".repeat(1 << 20), "es"]), Language::Es); + assert_eq!(l(&[&"de".repeat(1 << 19), "de-DE"]), Language::De); } /// #218: the `system` preference (the default) asks the host for the system's languages; an @@ -833,6 +875,28 @@ mod tests { } } + /// German covers at least the core menus (the Brazilian Portuguese entries, the top-level menus + /// and the native macOS Settings menu); every entry must still be a menu label. + #[test] + fn german_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!("de.tsv")); + for (_, en, _) in entries { + assert!(known(&en) || en == "Settings", "not a menu label: {en}"); + } + let (portuguese, _) = catalog::parse_entries(include_str!("pt-br.tsv")); + for (_, en, _) in portuguese { + 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", "Settings"]) { + assert!(Language::De.has(en), "untranslated German menu: {en}"); + } + assert_eq!(Language::De.tr("Settings"), "Voreinstellungen"); + } + #[test] fn ukrainian_renders_with_bundled_fonts() { let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9dff12d3..dbce3b26 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -28,6 +28,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ uic!("app.language.portuguese", "Português (Brasil)", ["Edit", "Language"], None), uic!("app.language.ukrainian", "Українська", ["Edit", "Language"], None), uic!("app.language.chinese", "简体中文", ["Edit", "Language"], None), + uic!("app.language.german", "Deutsch", ["Edit", "Language"], None), uic!("source.playback.toggle", "Source Play/Stop", [], None), uic!("source.playback.play", "Play Source", [], None), uic!("source.playback.stop", "Stop Source", [], None), @@ -180,6 +181,7 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: | "app.language.portuguese" | "app.language.ukrainian" | "app.language.chinese" + | "app.language.german" ) { // Japanese needs the craft-fonts (built with CRAFT_FONTS_DIR) or a font installed on the system if id == "app.language.japanese" && !crate::i18n::install_japanese_font(ctx) { @@ -195,6 +197,7 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: "app.language.portuguese" => crate::i18n::Language::PtBr, "app.language.ukrainian" => crate::i18n::Language::Uk, "app.language.chinese" => crate::i18n::Language::ZhCn, + "app.language.german" => crate::i18n::Language::De, _ => crate::i18n::Language::En, }; // The preference is updated in memory before it is written, so a failed write (read-only @@ -571,6 +574,7 @@ pub fn menu_items(app: &FilmcraftApp) -> Vec { "app.language.portuguese" => it.checked = Some(app.ui.language == crate::i18n::Language::PtBr), "app.language.ukrainian" => it.checked = Some(app.ui.language == crate::i18n::Language::Uk), "app.language.chinese" => it.checked = Some(app.ui.language == crate::i18n::Language::ZhCn), + "app.language.german" => it.checked = Some(app.ui.language == crate::i18n::Language::De), _ => {} } if it.id.starts_with("view.") { From 63c9b17454dfc557ceedeb085b327b5298e6501f Mon Sep 17 00:00:00 2001 From: Lukas Keller Date: Sat, 10 Oct 2026 13:06:02 +0200 Subject: [PATCH 021/144] contributors: credit @keller-lukas as Lukas Keller Co-Authored-By: Claude Opus 5.5 --- contributors/people.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 50788355..46dc3db2 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -56,3 +56,8 @@ sync_github_name = false [people.liangforstudy] sync_github_name = true + +[people.keller-lukas] +real_name = "Lukas Keller" +display_name = "Lukas Keller" +sync_github_name = false From 7123e1fc46d97aee235999447829cb1b3f2325ac Mon Sep 17 00:00:00 2001 From: Mario Podolan Date: Sat, 10 Oct 2026 14:43:28 +0200 Subject: [PATCH 022/144] ui: detect software rasterizers and say GPU acceleration is unavailable When wgpu falls back to a software rasterizer (llvmpipe / SwiftShader / softpipe - containers and VMs without GPU device access, missing drivers), set_wgpu accepted it as the GPU compositor (llvmpipe satisfies the Rgba16Float checks), so the preview quietly composited through WGSL on a CPU rasterizer: GPU usage 0 %, CPU saturated, laggy playback, no visible explanation. software_rasterizer() now detects CPU rasterizers (device_type == Cpu, or llvmpipe / SwiftShader / softpipe in the name) and gpu_compositor_unsupported rejects them first, so the monitors use the native CPU compositor, which renders the same frames faster than wgpu-on-llvmpipe. The status bar shows the reason ("GPU acceleration is unavailable; preview renders on the CPU (software rasterizer (...))") in addition to the log line. New tl! string with Spanish, Japanese and Simplified Chinese translations. Test: gpu_fallback_tests::software_rasterizers_are_detected. Co-Authored-By: Xiaomi MiMo --- crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/zh-cn.tsv | 1 + crates/ui-egui/src/lib.rs | 36 +++++++++++++++++++++++++++++++ 4 files changed, 39 insertions(+) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 8d15cde2..0d932ca5 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2940,6 +2940,7 @@ File Name & Location Nombre y ubicación del archivo Quick Export: {path} Exportación rápida: {path} Graphics problem: switched to software compositing Problema de gráficos: se ha cambiado a la composición por software + GPU acceleration is unavailable; preview renders on the CPU La aceleración por GPU no está disponible; la vista previa se renderiza en la CPU Exporting Exportando Click to select, or click in empty space and drag to marquee select. Use Shift, Opt, and Cmd for other options. Haz clic para seleccionar, o haz clic en un espacio vacío y arrastra para seleccionar con un marco. Usa Mayús, Opción y Cmd para otras opciones. Click to select all clips to the right in all tracks. Shift-click for a single track. Haz clic para seleccionar todos los clips a la derecha en todas las pistas. Mayús+clic para una sola pista. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index aa86c493..4bf45a80 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3501,3 +3501,4 @@ Solid べた塗り Clear Transition トランジションを消去 Set Transition Duration トランジションのデュレーションを設定 + GPU acceleration is unavailable; preview renders on the CPU GPU アクセラレーションを利用できません。プレビューは CPU でレンダリングされます diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index 476c4159..a4e11371 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -3490,3 +3490,4 @@ Sync with system 跟随系统 Clear Transition 清除过渡 Set Transition Duration 设置过渡持续时间 + GPU acceleration is unavailable; preview renders on the CPU GPU 加速不可用;预览在 CPU 上渲染 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d5437e97..7b7bfc8f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -330,12 +330,31 @@ fn plan_side(plan: &filmcraft_render::plan::FramePlan) -> usize { plan.max_side() } +/// A software rasterizer (llvmpipe, SwiftShader, softpipe — VMs and containers without GPU device +/// access, missing or broken drivers) runs wgpu entirely on the CPU. Compositing through one is +/// slower than the native CPU compositor and hides the cause: GPU usage stays at 0 % while the +/// CPU saturates. Returns a description when the adapter is one. +pub fn software_rasterizer(info: &eframe::wgpu::AdapterInfo) -> Option { + rasterizer_reason(&info.name, info.device_type) +} + +fn rasterizer_reason(name: &str, device_type: eframe::wgpu::DeviceType) -> Option { + let lower = name.to_ascii_lowercase(); + if device_type == eframe::wgpu::DeviceType::Cpu || ["llvmpipe", "swiftshader", "softpipe"].iter().any(|s| lower.contains(s)) { + return Some(format!("software rasterizer ({name}, {device_type:?})")); + } + None +} + /// Whether the GPU compositor can run on this adapter: it renders and blends Rgba16Float /// targets and uploads whole frames as textures. Older or OpenGL-backed GPUs (some Intel Macs, /// VMs, Linux without Vulkan, WebGL) can't; those use the CPU compositor, which renders the same /// frames. Returns the reason when unsupported. pub fn gpu_compositor_unsupported(adapter: &eframe::wgpu::Adapter) -> Option { use eframe::wgpu::{TextureFormat, TextureFormatFeatureFlags as F, TextureUsages as U}; + if let Some(why) = software_rasterizer(&adapter.get_info()) { + return Some(why); + } let f = adapter.get_texture_format_features(TextureFormat::Rgba16Float); if !f.allowed_usages.contains(U::RENDER_ATTACHMENT | U::TEXTURE_BINDING) { return Some("Rgba16Float is not renderable".into()); @@ -355,6 +374,9 @@ impl FilmcraftApp { pub fn set_wgpu(&mut self, rs: eframe::egui_wgpu::RenderState) { if let Some(why) = gpu_compositor_unsupported(&rs.adapter) { log::warn!("GPU compositor disabled ({why}); compositing on the CPU"); + // Say so instead of letting the preview quietly crawl: "GPU 0 %, CPU 100 %" with no + // explanation read like a renderer bug when the machine simply has no GPU access. + self.ui.status = format!("{} ({why})", tl!("GPU acceleration is unavailable; preview renders on the CPU")); return; } // wgpu's default reaction to a validation error or lost device is to panic, which closed @@ -1903,6 +1925,20 @@ mod clipboard_key_tests { mod gpu_fallback_tests { use filmcraft_render::plan::{FramePlan, PlanLayer}; + /// Software rasterizers must not be treated as a GPU compositor: the preview crawled while + /// "GPU" usage read 0 % because wgpu had silently fallen back to llvmpipe. + #[test] + fn software_rasterizers_are_detected() { + use eframe::wgpu::DeviceType as D; + let r = super::rasterizer_reason; + assert!(r("llvmpipe (LLVM 23.1.1, 256 bits)", D::Cpu).is_some()); + assert!(r("NVIDIA GeForce RTX 4080", D::Cpu).is_some()); + assert!(r("SwiftShader Device", D::Other).is_some()); + assert!(r("softpipe", D::Cpu).is_some()); + assert!(r("AMD Radeon RX 5700 XT", D::DiscreteGpu).is_none()); + assert!(r("Apple M3 Pro", D::IntegratedGpu).is_none()); + } + #[test] fn plan_side_covers_output_and_every_layer() { let frame = |w: u32, h: u32| std::sync::Arc::new(filmcraft_frame::VideoFrame::rgba_f32(w, h, vec![0.0; (w * h * 4) as usize])); From b92078ec7e1e8530eee7dda42227906d4184ac6b Mon Sep 17 00:00:00 2001 From: Katt <238968166+KattKnight@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:30:21 +0530 Subject: [PATCH 023/144] Media: a file that can't be opened is tried again once it changes, not on every look A media file the refresh couldn't open (still being written, or not media FilmCraft reads) kept its old stamp, so every look saw it as changed and opened it again on the UI thread. It now keeps its new stamp and is tried again once it changes again. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/media_watch.rs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/media_watch.rs b/crates/engine/src/media_watch.rs index b951f1f4..ddef6f11 100644 --- a/crates/engine/src/media_watch.rs +++ b/crates/engine/src/media_watch.rs @@ -14,7 +14,7 @@ //! media pool swaps in the new source (the old one still indexes the file as it was), and the //! view is refreshed without an undo step or modifying a clean project ([`Session::bump_view`]): //! the file changed, not the edit. A file seen for the first time is only remembered; one that -//! can't be opened yet keeps its old stamp, so the next look tries again. +//! can't be opened (still being written) is tried again once it changes again, not on every look. //! //! [`MediaInfo`]: filmcraft_media::MediaInfo @@ -142,12 +142,8 @@ impl Session { } } } - // Not readable yet (still being written): the next look tries again. - for path in &failed { - if let Some(old) = before.get(path) { - self.media_stamps.insert(path.clone(), *old); - } - } + // A file that can't be opened (still being written, or not media FilmCraft reads) keeps its + // new stamp: it is tried again once it changes again, not on every look. if fresh.is_empty() { return Ok(json!({"refreshed": []})); } From 8f0f689c855027d8706fb273564359f2b38ac035 Mon Sep 17 00:00:00 2001 From: JordyUSA <75485565+JordyUSA@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:10:39 +0700 Subject: [PATCH 024/144] Project panel: double-click anywhere empty in Icon view opens Import In Icon view each row of cards was laid out as a hover-only strip, so only the strip under the last row sensed clicks. With an empty project the whole panel is that strip and double-click opened Import; as soon as the project had cards (for example after placing audio in the timeline, which adds the clip and its sequence), most of the visible blank space sat beside or between the cards and ignored double-clicks. A click-sensing background now covers the visible Icon view behind the cards (registered first, so cards keep their own clicks) with the same handlers as the empty strip: click deselects, double-click imports, right-click opens the panel menu. Tests double-click with real pointer input at 60 fps: between and beside the demo project's bins, and beside the cards after a tone is placed on A1; both failed before (0 imports) and pass now. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/project_views.rs | 16 +++ crates/ui-egui/tests/project_panel_ui.rs | 117 +++++++++++++++++++++ 2 files changed, 133 insertions(+) diff --git a/crates/ui-egui/src/panels/project_views.rs b/crates/ui-egui/src/panels/project_views.rs index 7a54afae..381829bc 100644 --- a/crates/ui-egui/src/panels/project_views.rs +++ b/crates/ui-egui/src/panels/project_views.rs @@ -497,6 +497,21 @@ fn empty_space(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &View, actions: &mu let r = Rect::from_min_size(rest.min, vec2(rest.width(), rest.height().max(40.0))); let resp = ui.allocate_rect(r, Sense::click()); app.auto.add(&format!("{}.empty", v.prefix), r.intersect(ui.clip_rect()), "Empty area"); + empty_interactions(app, &resp, v, actions); +} + +/// The space between and beside the cards of the Icon view, which is empty too: registered before +/// the cards, so a card on top takes its own clicks and everything else lands here. Without it +/// only the strip under the last row answered, and once a project had a row of bins or clips that +/// strip was all a double-click could open Import from. +fn empty_background(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &View, actions: &mut Actions) { + let r = ui.clip_rect(); + let resp = ui.interact(r, ui.id().with((&v.prefix, "empty-background")), Sense::click()); + app.auto.add(&format!("{}.emptyBackground", v.prefix), r, "Empty area"); + empty_interactions(app, &resp, v, actions); +} + +fn empty_interactions(app: &mut FilmcraftApp, resp: &egui::Response, v: &View, actions: &mut Actions) { if resp.clicked() { app.ui.project_panel.selected_bin = None; actions.push(("project.select".into(), json!({"items": []}))); @@ -811,6 +826,7 @@ pub fn icon_view(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: &View app.ui.keys.icon_columns = per_row; } let rows = n.div_ceil(per_row); + empty_background(app, ui, v, actions); for rr in 0..rows { let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), cell.y), Sense::hover()); for k in 0..per_row { diff --git a/crates/ui-egui/tests/project_panel_ui.rs b/crates/ui-egui/tests/project_panel_ui.rs index bb504f32..a56b8928 100644 --- a/crates/ui-egui/tests/project_panel_ui.rs +++ b/crates/ui-egui/tests/project_panel_ui.rs @@ -626,3 +626,120 @@ fn media_browser_drag_imports_into_the_project_panel() { assert_eq!(d.app().session.project.items.len(), n0 + 1, "kept after a drop on the Timeline"); let _ = std::fs::remove_dir_all(&dir); } + +/// Double-clicking empty space in the Project panel opens Import, also between and beside the +/// Icon view's cards once the project has some. Only the strip under the last row used to answer, +/// so with a project loaded a double-click in the visible blank space did nothing. Real pointer +/// input at 60 frames per second, so the two clicks are a real double-click. +#[test] +fn double_click_empty_area_imports_with_a_project_loaded() { + fn driver(demo: bool) -> (Driver, std::sync::Arc) { + let mut session = Session::default(); + if demo { + session.execute("file.openDemoProject", json!({})).expect("demo project"); + } + let (tx, rx) = channel(); + let mut app = FilmcraftApp::new(session).with_control(rx); + let picks = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let seen = picks.clone(); + app.hooks.pick_files = Some(Box::new(move |_| { + seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Vec::new() + })); + 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, snapshots: None }; + d.frames(10); + (d, picks) + } + fn double_click(d: &mut Driver, at: egui::Pos2) { + let push = |d: &mut Driver, e: egui::Event| d.harness.input_mut().events.push(e); + // a pause first, as a person makes: clicks closer together continue the last gesture + // (a triple click) instead of starting a double-click + d.frames(60); + push(d, egui::Event::PointerMoved(at)); + d.frames(1); + for _ in 0..2 { + for pressed in [true, false] { + push(d, egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + d.frames(1); + } + } + d.frames(3); + } + let count = |p: &std::sync::Arc| p.load(std::sync::atomic::Ordering::SeqCst); + + // an empty project: the whole panel is the empty area + let (mut d, picks) = driver(false); + let r = d.rect("project.empty"); + double_click(&mut d, egui::pos2(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0)); + assert_eq!(count(&picks), 1, "empty project"); + + // the demo project, Icon view: the gap between the first two bins, and the space right of the + // last card in the first row + let (mut d, picks) = driver(true); + d.ok("ui.set", json!({})); + let (a, b) = (d.rect("project.bin.1"), d.rect("project.bin.2")); + assert!((a[1] - b[1]).abs() < 1.0 && b[0] > a[0] + a[2], "bins 1 and 2 share a row: {a:?} {b:?}"); + double_click(&mut d, egui::pos2((a[0] + a[2] + b[0]) / 2.0, a[1] + a[3] / 2.0)); + assert_eq!(count(&picks), 1, "between two bins"); + let row: Vec<[f32; 4]> = d.ids("project.bin.").iter().map(|id| d.rect(id)).filter(|r| (r[1] - a[1]).abs() < 1.0).collect(); + let last = row.iter().fold(a, |m, r| if r[0] > m[0] { *r } else { m }); + let panel = d.rect("project.count"); + if last[0] + last[2] + 30.0 < panel[0] + panel[2] { + double_click(&mut d, egui::pos2(last[0] + last[2] + 20.0, last[1] + last[3] / 2.0)); + assert_eq!(count(&picks), 2, "right of the last card"); + } + // a card still takes its own double-click: the bin opens, Import does not + double_click(&mut d, egui::pos2(a[0] + a[2] / 2.0, a[1] + a[3] / 2.0)); + let picked = count(&picks); + assert!(picked <= 2, "double-clicking a bin must not open Import"); +} + +/// The report: with audio placed in the timeline, double-clicking the Project panel's empty space +/// no longer opened Import. A new sequence with a tone on A1 (so the panel shows the sequence and +/// the audio clip as cards); every empty spot opens Import: the strip under the cards and the space +/// beside them. +#[test] +fn double_click_imports_with_audio_in_the_timeline() { + let mut session = Session::default(); + session.execute("file.newSequence", json!({"name": "Mix", "audio": 2, "video": 1})).unwrap(); + let inter: Vec = (0..48_000).flat_map(|i| [(i as f32 * 0.0576).sin() * 0.5; 2]).collect(); + let bytes: std::sync::Arc<[u8]> = filmcraft_engine::previews::write_wav_f32(&inter, 48_000).into(); + let item = filmcraft_engine::commands::import_bytes(&mut session, "/tone.wav", bytes, None).unwrap(); + session.execute("timeline.place", json!({"item": item.0, "audioTrack": "A1", "seconds": 0.0})).unwrap(); + let (tx, rx) = channel(); + let mut app = FilmcraftApp::new(session).with_control(rx); + let picks = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let seen = picks.clone(); + app.hooks.pick_files = Some(Box::new(move |_| { + seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Vec::new() + })); + 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, snapshots: None }; + d.frames(10); + let mut double_click = |d: &mut Driver, at: egui::Pos2| { + let push = |d: &mut Driver, e: egui::Event| d.harness.input_mut().events.push(e); + d.frames(60); + push(d, egui::Event::PointerMoved(at)); + d.frames(1); + for _ in 0..2 { + for pressed in [true, false] { + push(d, egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + d.frames(1); + } + } + d.frames(3); + }; + let count = || picks.load(std::sync::atomic::Ordering::SeqCst); + let cards: Vec<[f32; 4]> = d.ids("project.item.").iter().filter(|id| id.matches('.').count() == 2).map(|id| d.rect(id)).collect(); + assert!(!cards.is_empty(), "the sequence and the tone show as cards"); + let empty = d.rect("project.empty"); + eprintln!("cards {cards:?}, empty strip {empty:?}"); + double_click(&mut d, egui::pos2(empty[0] + empty[2] / 2.0, empty[1] + empty[3] / 2.0)); + let strip = count(); + let last = cards.iter().fold(cards[0], |m, r| if r[0] > m[0] { *r } else { m }); + double_click(&mut d, egui::pos2(last[0] + last[2] + 40.0, last[1] + last[3] / 2.0)); + let beside = count() - strip; + assert_eq!((strip, beside), (1, 1), "double-click on (the strip under the cards, the space beside them)"); +} From 1e3d4a8a8cc4deb5d0bf29de56a50364e6208377 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 15:47:11 +0200 Subject: [PATCH 025/144] #591: named clips that cannot stand in for a selection say why MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: a command that takes `clips` / `clip` answered "no clips selected" when the caller named clips that are not in the active sequence. Over MCP or the control channel that reads as if the explicit ids were ignored: a clip id of another sequence (for example from `sequence.inspect {"item": …}`), an id that is no clip at all (a track or project item id from the same inspect output) and a string id like "29" all gave the same message. Fix: when the selection check fails and the parameters named clips under a key the command documents, but none is a clip of the active sequence, the reason names the first one and what is wrong with it: the sequence it is in (with the `sequence.open` call to make it active), that no clip has that id, or that the value is not a clip id. Enablement itself is unchanged: such a call is still refused, and a call that names nothing keeps the selection's reason. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands.rs | 23 ++++++++++ crates/engine/src/explicit_targets_tests.rs | 47 ++++++++++++++++++++- crates/engine/src/lib.rs | 7 ++- 3 files changed, 73 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 0b3a9f8a..bc07eef0 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -140,6 +140,29 @@ pub(crate) fn named_clips(s: &Session, spec: &CommandSpec, p: &Value) -> Option< let clips: Vec = named_ids(spec, p, "clips", "clip")?.into_iter().map(ClipId).filter(|c| seq.find_item(*c).is_some()).collect(); (!clips.is_empty()).then_some(clips) } +/// Why the clips a caller named in `clips` / `clip` stand in for no selection (#591): a clip of +/// another sequence, an id of no clip (a track, a project item…) or a value that is not an id. +/// `None` when the caller named nothing or no sequence is open, so the selection's reason stands. +pub(crate) fn named_clips_missing(s: &Session, spec: &CommandSpec, p: &Value) -> Option { + s.active_sequence()?; + let first = if spec.params.contains("\"clips\":[id]") + && let Some(a) = p.get("clips").and_then(Value::as_array) + { + a.first()? + } else if spec.params.contains("\"clip\":id") { + p.get("clip").filter(|v| !v.is_null())? + } else { + return None; + }; + let Some(id) = first.as_u64() else { + return Some(format!("{first} is not a clip id (clip ids are the `clip` numbers of the items in sequence.inspect)")); + }; + let elsewhere = s.project.sequences().find(|i| i.as_sequence().is_some_and(|q| q.find_item(ClipId(id)).is_some())); + Some(match elsewhere { + Some(i) => format!("clip {id} is in sequence \"{}\", not the active one: open it first with sequence.open {{\"item\":{}}}", i.name, i.id.0), + None => format!("no clip {id} in the active sequence (clip ids are the `clip` numbers of the items in sequence.inspect)"), + }) +} /// Project items named explicitly in `items` / `item`, likewise. The Project panel's selection /// holds bins beside items (one id space, see `project.select`), so a bin named here counts too; /// the project's own top bin does not, since no command acts on it (#244). diff --git a/crates/engine/src/explicit_targets_tests.rs b/crates/engine/src/explicit_targets_tests.rs index ce377f64..c3df07e1 100644 --- a/crates/engine/src/explicit_targets_tests.rs +++ b/crates/engine/src/explicit_targets_tests.rs @@ -119,10 +119,15 @@ fn clip_commands_take_explicit_clips_without_a_selection() { fn naming_nothing_usable_does_not_enable_a_command() { let mut s = demo(); let before = s.project.clone(); - // no ids, ids of no clip, the wrong type, and a key the command does not take - for p in [json!({"clips": []}), json!({"clips": [987_654_321u64]}), json!({"clips": "all"}), json!({"clips": [-1, 1.5, null]}), json!({"items": [1]})] { + // no ids, the wrong type, and a key the command does not take + for p in [json!({"clips": []}), json!({"clips": "all"}), json!({"items": [1]})] { assert_eq!(disabled(s.execute("edit.rippleDelete", p.clone())), "nothing selected", "{p}"); } + // ids of no clip and values that are not ids: still disabled, and the reason names them (#591) + let why = disabled(s.execute("edit.rippleDelete", json!({"clips": [987_654_321u64]}))); + assert!(why.starts_with("no clip 987654321 in the active sequence"), "{why}"); + let why = disabled(s.execute("edit.rippleDelete", json!({"clips": [-1, 1.5, null]}))); + 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"); @@ -159,6 +164,44 @@ fn link_and_speed_take_explicit_clips_without_a_selection() { assert!(s.state.selection.is_empty() && !s.is_enabled("clip.speedDuration"), "menu enablement still follows the selection"); } +/// #591: a caller that names clips which cannot stand in for the selection hears why, instead of +/// "no clips selected": a clip of another sequence, an id of something else, a string id. +#[test] +fn named_clips_that_are_not_usable_say_why() { + let mut s = demo(); + let demo_seq = s.state.active_sequence.unwrap(); + let demo_name = s.project.item(demo_seq).unwrap().name.clone(); + let last = v1(&s).last().unwrap().clone(); + // a track id and a project item id are not clip ids + let track = s.active_sequence().unwrap().video_tracks[0].id.0; + let why = disabled(s.execute("clip.speedDuration", json!({"clips": [track], "speed": 50}))); + assert_eq!(why, format!("no clip {track} in the active sequence (clip ids are the `clip` numbers of the items in sequence.inspect)")); + let why = disabled(s.execute("clip.speedDuration", json!({"clips": [last.item.0], "speed": 50}))); + assert!(why.starts_with(&format!("no clip {} in the active sequence", last.item.0)), "{why}"); + // a string is not an id + let why = disabled(s.execute("clip.speedDuration", json!({"clips": [last.id.0.to_string()], "speed": 50}))); + assert!(why.starts_with(&format!("\"{}\" is not a clip id", last.id.0)), "{why}"); + // `clip`, where the command documents it + let why = disabled(s.execute("graphics.setText", json!({"clip": "x", "text": "x"}))); + assert!(why.starts_with("\"x\" is not a clip id"), "{why}"); + // a clip of a sequence that is not the active one + s.execute("file.newSequence", json!({"name": "Other"})).unwrap(); + assert_ne!(s.state.active_sequence, Some(demo_seq)); + let why = disabled(s.execute("clip.speedDuration", json!({"clips": [last.id.0], "speed": 200}))); + assert_eq!( + why, + format!("clip {} is in sequence \"{demo_name}\", not the active one: open it first with sequence.open {{\"item\":{}}}", last.id.0, demo_seq.0) + ); + // and doing what it says works + s.execute("sequence.open", json!({"item": demo_seq.0})).unwrap(); + s.execute("clip.speedDuration", json!({"clips": [last.id.0], "speed": 200})).unwrap(); + assert!(clip(&s, last.id).unwrap().duration < last.duration); + // naming nothing keeps the selection's reason, for menus and callers alike + s.state.selection.clear(); + assert_eq!(disabled(s.execute("clip.speedDuration", json!({"speed": 50}))), "no clips selected"); + assert_eq!(disabled(s.execute("clip.speedDuration", json!({"clips": [], "speed": 50}))), "no clips selected"); +} + #[test] fn naming_targets_lifts_only_the_selection_condition() { // no sequence: named clips cannot stand in for one diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index eea55b32..b2779f64 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -694,8 +694,11 @@ impl Session { } let clips = commands::named_clips(self, spec, params); let items = commands::named_items(self, spec, params); + // clips were named but none is one of the active sequence: say which and why, rather + // than "no clips selected" to a caller that did not mean the selection (#591) + let missing = if clips.is_none() { commands::named_clips_missing(self, spec, params) } else { None }; if clips.is_none() && items.is_none() { - return by_selection; + return by_selection.map_err(|why| missing.unwrap_or(why)); } let mut clips = clips.unwrap_or_else(|| self.state.selection.clone()); let mut items = items.unwrap_or_else(|| self.state.project_selection.clone()); @@ -704,7 +707,7 @@ impl Session { let by_params = (spec.enabled)(self); self.state.selection = clips; self.state.project_selection = items; - by_params + by_params.map_err(|why| missing.unwrap_or(why)) } /// Whether the command can run on the current selection (what the menus show). From 1169ef2a9323613015667b9de2f2ea329c3dcba0 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 15:48:59 +0200 Subject: [PATCH 026/144] contributors: credit vangel76 Co-Authored-By: Claude Opus 5.5 --- contributors/people.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 50788355..d461a335 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -56,3 +56,8 @@ sync_github_name = false [people.liangforstudy] sync_github_name = true + +[people.vangel76] +real_name = "Valentin Klein" +display_name = "Valentin Klein" +sync_github_name = false From 4fcc437bf6c544cb459f20196ba90570b2cc032f Mon Sep 17 00:00:00 2001 From: Max Murphy Date: Sat, 10 Oct 2026 10:04:40 -0400 Subject: [PATCH 027/144] perf(export): index the bin tree once in closure() The bin/item id split checked p.root.find_bin for every queued id, and find_bin walks the whole tree for an item id, making a flat N-item export O(N^2). Build the bin id set and each bin's direct children once, then test membership and expand from the index, so closure() is O(N log N) and bin expansion never re-walks the tree. --- crates/engine/src/project_tools.rs | 44 +++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/project_tools.rs b/crates/engine/src/project_tools.rs index a23e7530..a0e2d181 100644 --- a/crates/engine/src/project_tools.rs +++ b/crates/engine/src/project_tools.rs @@ -22,9 +22,9 @@ //! | `clip.automateToSequence` | Clip ▸ Automate to Sequence… | //! | `help.systemReport` | query behind Help ▸ System Compatibility Report… | -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; -use filmcraft_project::{BinEntry, BinId, FindOp, FindQuery, FindRow, ItemId, ItemKind, MarkerKind, MediaRef, Project, SearchBin, TrackKind}; +use filmcraft_project::{BinEntry, FindOp, FindQuery, FindRow, ItemId, ItemKind, MarkerKind, MediaRef, Project, SearchBin, TrackKind}; use filmcraft_time::{Tick, TimeDisplay, TimeRange}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -704,18 +704,42 @@ fn new_from_template(s: &mut Session, p: &Value) -> Result { /// everything those items need), as `project.delete` does. The project's own top bin does not, /// since no command acts on it (#244), and only ids that name a real item end up in the result. pub fn closure(p: &Project, roots: &[ItemId]) -> BTreeSet { + // Index the bin tree once. Told apart from items with a set membership test (rather than a + // full `find_bin` per queued id, which is O(n²) when exporting many items from a flat + // project) and expanded from the indexed children (rather than a second walk per bin). + let mut bins: BTreeSet = BTreeSet::new(); + let mut children: BTreeMap, Vec)> = BTreeMap::new(); + let mut bin_stack = vec![&p.root]; + while let Some(b) = bin_stack.pop() { + bins.insert(b.id.0); + let mut direct = (Vec::new(), Vec::new()); + for c in &b.children { + match c { + BinEntry::Item(i) => direct.0.push(*i), + BinEntry::Bin(x) => { + direct.1.push(x.id.0); + bin_stack.push(x); + } + } + } + children.insert(b.id.0, direct); + } + let mut keep: BTreeSet = BTreeSet::new(); let mut todo: Vec = roots.to_vec(); let mut seen_bins: BTreeSet = BTreeSet::new(); + let mut bin_todo: Vec = Vec::new(); while let Some(id) = todo.pop() { - let bin = BinId(id.0); - if bin != p.root.id && p.root.find_bin(bin).is_some() { - if seen_bins.insert(id.0) - && let Some(b) = p.root.find_bin(bin) - { - let mut items = Vec::new(); - b.all_items(&mut items); - todo.extend(items); + if bins.contains(&id.0) && id.0 != p.root.id.0 { + bin_todo.push(id.0); + while let Some(bid) = bin_todo.pop() { + if !seen_bins.insert(bid) { + continue; + } + if let Some((items, subs)) = children.get(&bid) { + todo.extend(items.iter().copied()); + bin_todo.extend(subs.iter().copied()); + } } continue; } From 23b4bf24b13c72ba2a261c1b2f3b6cce2e2d144b Mon Sep 17 00:00:00 2001 From: Max Murphy Date: Sat, 10 Oct 2026 10:02:07 -0400 Subject: [PATCH 028/144] MP4: read the start timecode from a Sony rtmd track when there is no tmcd Sony XAVC cameras such as the FX3 and a6400 keep the clip's start timecode in an rtmd (real-time metadata) data track rather than a QuickTime tmcd track, so probe reported start_timecode: null and timecode sync could not use the footage. Read the hours/minutes/seconds/drop-frame/frames block from the first rtmd sample and expose it as MediaInfo::start_timecode, falling back only when no tmcd track is present (#460). --- crates/codecs/src/mp4.rs | 34 +++++ crates/codecs/tests/mp4_rtmd.rs | 225 ++++++++++++++++++++++++++++++++ 2 files changed, 259 insertions(+) create mode 100644 crates/codecs/tests/mp4_rtmd.rs diff --git a/crates/codecs/src/mp4.rs b/crates/codecs/src/mp4.rs index 62f4f547..dcb23c01 100644 --- a/crates/codecs/src/mp4.rs +++ b/crates/codecs/src/mp4.rs @@ -162,6 +162,25 @@ fn color_from(entry: &filmcraft_isobmff::SampleEntry, w: u32, h: u32) -> ColorIn crate::stream_color::resolve(w, h, &sources) } +/// Sony XAVC `rtmd` (real-time metadata) start timecode and drop-frame flag, read from the first +/// sample. Sony stores the fields as raw decimal bytes in the sample's fixed header: hours, +/// minutes, seconds, a drop-frame flag, then frames. The layout is proprietary and undocumented, +/// so it is reproduced here from real footage; it is the same block ffmpeg reports as the +/// `timecode` tag of an `rtmd` stream. +fn rtmd_fields(sample: &[u8]) -> Option<(i64, i64, i64, i64, bool)> { + let f = sample.get(0x0d..0x12)?; + Some((f[0] as i64, f[1] as i64, f[2] as i64, f[4] as i64, f[3] != 0)) +} + +/// Start timecode from a Sony `rtmd` track as a frame count at `rate` (issue #460), used when the +/// file has no `tmcd` track. `None` if there is no `rtmd` track or it has no usable first sample. +fn rtmd_start_timecode(file: &Mp4File, bytes: &crate::Src, rate: FrameRate) -> Option { + let ti = file.tracks.iter().position(|t| t.entries.first().is_some_and(|e| e.format.0 == *b"rtmd"))?; + let sample = file.read_sample(bytes, ti, 0).ok()?; + let (h, m, s, f, drop) = rtmd_fields(&sample)?; + Some(filmcraft_time::fields_to_frames(h, m, s, f, rate, drop)) +} + impl Mp4Source { pub fn open(name: &str, bytes: Arc<[u8]>) -> crate::Result { Self::open_reader(name, Arc::new(filmcraft_media::reader::MemReader(bytes))) @@ -271,6 +290,9 @@ impl Mp4Source { Some(CodecConfig::Timecode(tc)) => tc.start_frame.map(|f| f as i64), _ => None, }); + // Sony XAVC files with no `tmcd` track carry the start timecode in an `rtmd` metadata + // track; convert its fields to a frame count using the video frame rate. + let start_timecode = start_timecode.or_else(|| rtmd_start_timecode(&file, &bytes, video.as_ref()?.frame_rate)); let info = MediaInfo { name: name.to_string(), kind: if video.is_some() { MediaKind::Movie } else { MediaKind::AudioOnly }, @@ -914,6 +936,18 @@ mod tests { assert_eq!((v.pixel_format.as_str(), v.has_alpha), (want, alpha), "{}", String::from_utf8_lossy(fourcc)); } } + + #[test] + fn rtmd_fields_parse_and_ignore_short_samples() { + // 03:37:12:34, non-drop (a Sony FX3/a6400 XAVC header). + let mut s = [0u8; 24]; + s[0x0d..0x12].copy_from_slice(&[3, 37, 12, 0, 34]); + assert_eq!(rtmd_fields(&s), Some((3, 37, 12, 34, false))); + s[0x10] = 1; + assert_eq!(rtmd_fields(&s), Some((3, 37, 12, 34, true))); + // Too short to hold the block: no fields, no panic. + assert_eq!(rtmd_fields(&[0u8; 6]), None); + } } #[cfg(test)] diff --git a/crates/codecs/tests/mp4_rtmd.rs b/crates/codecs/tests/mp4_rtmd.rs new file mode 100644 index 00000000..34b8e18b --- /dev/null +++ b/crates/codecs/tests/mp4_rtmd.rs @@ -0,0 +1,225 @@ +//! Sony XAVC MP4 with no `tmcd` track: the start timecode lives in an `rtmd` metadata track +//! (issue #460). A hand-built file exercises the whole probe path; the byte layout of the `rtmd` +//! sample is the one real XAVC footage uses (checked against ffprobe's `timecode` tag). + +use std::sync::Arc; + +use filmcraft_time::FrameRate; + +/// Minimal ISO-BMFF box writer (the demuxer only needs headers and sample tables here). +#[derive(Default)] +struct B(Vec); + +impl B { + fn u16(&mut self, v: u16) { + self.0.extend_from_slice(&v.to_be_bytes()); + } + fn u32(&mut self, v: u32) { + self.0.extend_from_slice(&v.to_be_bytes()); + } + fn bytes(&mut self, b: &[u8]) { + self.0.extend_from_slice(b); + } + fn zeros(&mut self, n: usize) { + self.0.resize(self.0.len() + n, 0); + } + /// A container box (`size` + `kind` + children). + fn container(&mut self, kind: &[u8; 4], f: impl FnOnce(&mut B)) { + let at = self.0.len(); + self.u32(0); + self.bytes(kind); + f(self); + let len = (self.0.len() - at) as u32; + self.0[at..at + 4].copy_from_slice(&len.to_be_bytes()); + } + /// A full box (`size` + `kind` + version/flags + body). + fn full(&mut self, kind: &[u8; 4], f: impl FnOnce(&mut B)) { + self.container(kind, |b| { + b.zeros(4); + f(b); + }); + } + fn leaf(&mut self, kind: &[u8; 4], payload: &[u8]) { + self.container(kind, |b| b.bytes(payload)); + } +} + +/// A `stsd` visual sample entry (JPEG keeps the video track decodable enough to open). +fn video_entry(b: &mut B) { + b.bytes(&[0; 6]); + b.u16(1); + b.u16(0); + b.u16(0); + b.bytes(&[0; 4]); + b.u32(0); + b.u32(0); + b.u16(1920); + b.u16(1080); + b.u32(0x0048_0000); + b.u32(0x0048_0000); + b.u32(0); + b.u16(1); + b.bytes(&[0; 32]); + b.u16(24); + b.u16(0xFFFF); +} + +/// A video track at 59.94 fps, plus a Sony `rtmd` track whose first sample is `rtmd_sample`. +fn file_with_rtmd(rtmd_sample: &[u8]) -> Vec { + let mut b = B::default(); + b.leaf(b"ftyp", b"isom\x00\x00\x00\x00isom"); + let mdat_start = b.0.len(); + let mdat: Vec = [&[1, 2, 3, 4][..], rtmd_sample].concat(); + b.leaf(b"mdat", &mdat); + let video_off = (mdat_start + 8) as u32; + let rtmd_off = video_off + 4; + b.container(b"moov", |b| { + b.full(b"mvhd", |b| { + b.zeros(8); + b.u32(1000); + b.u32(1001); + b.zeros(80); + }); + // Video track (id 1): one sample of 1001/60000 s → 59.94 fps. + b.container(b"trak", |b| { + b.full(b"tkhd", |b| { + b.zeros(8); + b.u32(1); + b.zeros(4); + b.u32(1001); + b.zeros(52); + b.u32(1920 << 16); + b.u32(1080 << 16); + }); + b.container(b"mdia", |b| { + b.full(b"mdhd", |b| { + b.zeros(8); + b.u32(60000); + b.u32(1001); + b.u16(0x55C4); + b.u16(0); + }); + b.full(b"hdlr", |b| { + b.zeros(4); + b.bytes(b"vide"); + b.zeros(12); + b.bytes(b"Video\0"); + }); + b.container(b"minf", |b| { + b.container(b"stbl", |b| { + b.full(b"stsd", |b| { + b.u32(1); + b.container(b"jpeg", video_entry); + }); + b.full(b"stts", |b| { + b.u32(1); + b.u32(1); + b.u32(1001); + }); + b.full(b"stsc", |b| { + b.u32(1); + b.u32(1); + b.u32(1); + b.u32(1); + }); + b.full(b"stsz", |b| { + b.u32(0); + b.u32(1); + b.u32(4); + }); + b.full(b"stco", |b| { + b.u32(1); + b.u32(video_off); + }); + }); + }); + }); + }); + // Sony rtmd metadata track (id 2). + b.container(b"trak", |b| { + b.full(b"tkhd", |b| { + b.zeros(8); + b.u32(2); + b.zeros(4); + b.u32(1); + b.zeros(52); + b.zeros(8); + }); + b.container(b"mdia", |b| { + b.full(b"mdhd", |b| { + b.zeros(8); + b.u32(1); + b.u32(1); + b.u16(0x55C4); + b.u16(0); + }); + b.full(b"hdlr", |b| { + b.zeros(4); + b.bytes(b"meta"); + b.zeros(12); + b.bytes(b"Timed Metadata\0"); + }); + b.container(b"minf", |b| { + b.container(b"stbl", |b| { + b.full(b"stsd", |b| { + b.u32(1); + b.leaf(b"rtmd", &[0, 0, 0, 0, 0, 0, 0, 1]); + }); + b.full(b"stts", |b| { + b.u32(1); + b.u32(1); + b.u32(1); + }); + b.full(b"stsc", |b| { + b.u32(1); + b.u32(1); + b.u32(1); + b.u32(1); + }); + b.full(b"stsz", |b| { + b.u32(0); + b.u32(1); + b.u32(rtmd_sample.len() as u32); + }); + b.full(b"stco", |b| { + b.u32(1); + b.u32(rtmd_off); + }); + }); + }); + }); + }); + }); + b.0 +} + +/// A Sony `rtmd` sample: hours, minutes, seconds at 0x0d–0x0f, a drop-frame flag at 0x10 and the +/// frame number at 0x11, then the start of the SMPTE KLV key. +fn rtmd_sample(h: u8, m: u8, s: u8, f: u8, drop: u8) -> Vec { + let mut v = vec![0u8; 24]; + v[0x0d] = h; + v[0x0e] = m; + v[0x0f] = s; + v[0x10] = drop; + v[0x11] = f; + v[0x12..0x16].copy_from_slice(&[0x06, 0x0e, 0x2b, 0x34]); + v +} + +#[test] +fn rtmd_start_timecode_is_read() { + let file = file_with_rtmd(&rtmd_sample(3, 37, 12, 34, 0)); + let src = filmcraft_codecs::open_bytes("fx3.mp4", Arc::from(file.into_boxed_slice())).unwrap(); + let rate = FrameRate::new(60000, 1001); + let tc = src.info().start_timecode.expect("rtmd start timecode"); + assert_eq!(tc, 781_954); + assert_eq!(filmcraft_time::format_timecode_frames(tc, rate, false), "03:37:12:34"); +} + +#[test] +fn rtmd_without_usable_sample_is_ignored() { + // Too short to carry the timecode block: no start timecode, and no panic. + let file = file_with_rtmd(&[0u8; 6]); + let src = filmcraft_codecs::open_bytes("short.mp4", Arc::from(file.into_boxed_slice())).unwrap(); + assert_eq!(src.info().start_timecode, None); +} From cfebe8df1423350658f10565b4f244fcc265e1bf Mon Sep 17 00:00:00 2001 From: Max Murphy Date: Sat, 10 Oct 2026 10:02:07 -0400 Subject: [PATCH 029/144] Contributors: credit maxm11 (Max Murphy) --- contributors/people.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 50788355..4af53d71 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -56,3 +56,8 @@ sync_github_name = false [people.liangforstudy] sync_github_name = true + +[people.maxm11] +real_name = "Max Murphy" +display_name = "Max Murphy" +sync_github_name = false From 83fb73b9b1e6ba065281a56dd117ec3d47ffdaf9 Mon Sep 17 00:00:00 2001 From: Max Murphy Date: Sat, 10 Oct 2026 10:13:31 -0400 Subject: [PATCH 030/144] MP4 rtmd: bound the header read and reject damaged timecode fields Address review of #460: - Read only the fixed-size rtmd header (18 bytes) instead of read_sample, which allocated the whole untrusted stsz size (a corrupt multi-GiB size could exhaust memory during import). - Validate hours/minutes/seconds/frames against the frame base and drop the start timecode when they are out of range, instead of normalizing damaged metadata into a plausible but wrong offset. --- crates/codecs/src/mp4.rs | 52 +++++++++++++++++++++++---------- crates/codecs/tests/mp4_rtmd.rs | 25 +++++++++++++++- 2 files changed, 61 insertions(+), 16 deletions(-) diff --git a/crates/codecs/src/mp4.rs b/crates/codecs/src/mp4.rs index dcb23c01..2c1b4c59 100644 --- a/crates/codecs/src/mp4.rs +++ b/crates/codecs/src/mp4.rs @@ -9,7 +9,7 @@ use std::sync::{Arc, Mutex}; use filmcraft_color::ColorInfo; use filmcraft_frame::{AudioBuffer, Region, VideoFrame}; -use filmcraft_isobmff::{CleanAperture, CodecConfig, Mp4File, TrackKind}; +use filmcraft_isobmff::{ByteSource, CleanAperture, CodecConfig, Mp4File, TrackKind}; use filmcraft_media::{AudioStreamInfo, FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource, SharedSource, VideoStreamInfo}; use filmcraft_time::{FrameRate, Tick}; @@ -162,22 +162,38 @@ fn color_from(entry: &filmcraft_isobmff::SampleEntry, w: u32, h: u32) -> ColorIn crate::stream_color::resolve(w, h, &sources) } -/// Sony XAVC `rtmd` (real-time metadata) start timecode and drop-frame flag, read from the first -/// sample. Sony stores the fields as raw decimal bytes in the sample's fixed header: hours, -/// minutes, seconds, a drop-frame flag, then frames. The layout is proprietary and undocumented, -/// so it is reproduced here from real footage; it is the same block ffmpeg reports as the -/// `timecode` tag of an `rtmd` stream. -fn rtmd_fields(sample: &[u8]) -> Option<(i64, i64, i64, i64, bool)> { +/// First bytes of a Sony `rtmd` sample needed for the start-timecode block (`frames` is last). +const RTMD_HEADER_LEN: usize = 0x12; + +/// Sony XAVC `rtmd` (real-time metadata) start timecode fields, read from the first sample: hours, +/// minutes, seconds, a drop-frame flag and frames. Sony stores them as raw decimal bytes in the +/// sample's fixed header. The layout is proprietary and undocumented, so it is reproduced here from +/// real footage; it is the same block ffmpeg reports as the `timecode` tag of an `rtmd` stream. +/// +/// Fields outside SMPTE bounds are rejected (no fabricated start time from damaged metadata); +/// `base` is the timecode frame base (24, 25, 30, 60…), so a frame number must be below it. +fn rtmd_fields(sample: &[u8], base: i64) -> Option<(i64, i64, i64, i64, bool)> { let f = sample.get(0x0d..0x12)?; - Some((f[0] as i64, f[1] as i64, f[2] as i64, f[4] as i64, f[3] != 0)) + let (h, m, s) = (f[0] as i64, f[1] as i64, f[2] as i64); + let frames = f[4] as i64; + if h > 23 || m > 59 || s > 59 || frames >= base.max(1) { + return None; + } + Some((h, m, s, frames, f[3] != 0)) } /// Start timecode from a Sony `rtmd` track as a frame count at `rate` (issue #460), used when the /// file has no `tmcd` track. `None` if there is no `rtmd` track or it has no usable first sample. +/// Only the fixed-size header is read (a damaged `stsz` size can't drive a huge allocation). fn rtmd_start_timecode(file: &Mp4File, bytes: &crate::Src, rate: FrameRate) -> Option { - let ti = file.tracks.iter().position(|t| t.entries.first().is_some_and(|e| e.format.0 == *b"rtmd"))?; - let sample = file.read_sample(bytes, ti, 0).ok()?; - let (h, m, s, f, drop) = rtmd_fields(&sample)?; + let t = file.tracks.iter().find(|t| t.entries.first().is_some_and(|e| e.format.0 == *b"rtmd"))?; + let s = t.samples.first()?; + if s.size < RTMD_HEADER_LEN as u32 { + return None; + } + let mut buf = [0u8; RTMD_HEADER_LEN]; + bytes.read_at(s.offset, &mut buf).ok()?; + let (h, m, s, f, drop) = rtmd_fields(&buf, rate.timecode_base())?; Some(filmcraft_time::fields_to_frames(h, m, s, f, rate, drop)) } @@ -938,15 +954,21 @@ mod tests { } #[test] - fn rtmd_fields_parse_and_ignore_short_samples() { + fn rtmd_fields_parse_and_reject_damaged_metadata() { // 03:37:12:34, non-drop (a Sony FX3/a6400 XAVC header). let mut s = [0u8; 24]; s[0x0d..0x12].copy_from_slice(&[3, 37, 12, 0, 34]); - assert_eq!(rtmd_fields(&s), Some((3, 37, 12, 34, false))); + assert_eq!(rtmd_fields(&s, 60), Some((3, 37, 12, 34, false))); s[0x10] = 1; - assert_eq!(rtmd_fields(&s), Some((3, 37, 12, 34, true))); + assert_eq!(rtmd_fields(&s, 60), Some((3, 37, 12, 34, true))); // Too short to hold the block: no fields, no panic. - assert_eq!(rtmd_fields(&[0u8; 6]), None); + assert_eq!(rtmd_fields(&[0u8; 6], 60), None); + // Damaged fields are rejected rather than normalized into a plausible time. + for bad in [[3, 37, 12, 0, 60], [3, 60, 0, 0, 0], [24, 0, 0, 0, 0], [0, 0, 60, 0, 0]] { + let mut b = [0u8; 24]; + b[0x0d..0x12].copy_from_slice(&bad); + assert_eq!(rtmd_fields(&b, 60), None, "{bad:?}"); + } } } diff --git a/crates/codecs/tests/mp4_rtmd.rs b/crates/codecs/tests/mp4_rtmd.rs index 34b8e18b..ae937d79 100644 --- a/crates/codecs/tests/mp4_rtmd.rs +++ b/crates/codecs/tests/mp4_rtmd.rs @@ -66,6 +66,12 @@ fn video_entry(b: &mut B) { /// A video track at 59.94 fps, plus a Sony `rtmd` track whose first sample is `rtmd_sample`. fn file_with_rtmd(rtmd_sample: &[u8]) -> Vec { + file_with_rtmd_claimed(rtmd_sample, rtmd_sample.len() as u32) +} + +/// As [`file_with_rtmd`] but the `rtmd` sample's `stsz` size is `claimed` (which may exceed the +/// bytes actually present, as a corrupt file can). +fn file_with_rtmd_claimed(rtmd_sample: &[u8], claimed: u32) -> Vec { let mut b = B::default(); b.leaf(b"ftyp", b"isom\x00\x00\x00\x00isom"); let mdat_start = b.0.len(); @@ -179,7 +185,7 @@ fn file_with_rtmd(rtmd_sample: &[u8]) -> Vec { b.full(b"stsz", |b| { b.u32(0); b.u32(1); - b.u32(rtmd_sample.len() as u32); + b.u32(claimed); }); b.full(b"stco", |b| { b.u32(1); @@ -223,3 +229,20 @@ fn rtmd_without_usable_sample_is_ignored() { let src = filmcraft_codecs::open_bytes("short.mp4", Arc::from(file.into_boxed_slice())).unwrap(); assert_eq!(src.info().start_timecode, None); } + +#[test] +fn huge_claimed_rtmd_sample_does_not_exhaust_memory() { + // The stsz size claims 16 MiB but only the header bytes exist: only the fixed-size header is + // read, so a corrupt size cannot drive a huge allocation during open (issue #460 review). + let file = file_with_rtmd_claimed(&rtmd_sample(1, 2, 3, 4, 0), 16 * 1024 * 1024); + let src = filmcraft_codecs::open_bytes("big.mp4", Arc::from(file.into_boxed_slice())).unwrap(); + assert_eq!(src.info().start_timecode, Some(223_384)); // 01:02:03:04 at 59.94 +} + +#[test] +fn damaged_timecode_fields_are_not_published() { + // Frame 60 at 59.94 fps (valid range is 0–59): unusable metadata, not a fabricated time. + let file = file_with_rtmd(&rtmd_sample(3, 37, 12, 60, 0)); + let src = filmcraft_codecs::open_bytes("bad.mp4", Arc::from(file.into_boxed_slice())).unwrap(); + assert_eq!(src.info().start_timecode, None); +} From 2472b890aca724272695df59786cc3bf4e36c7ce Mon Sep 17 00:00:00 2001 From: Max Murphy Date: Sat, 10 Oct 2026 10:18:27 -0400 Subject: [PATCH 031/144] MP4 rtmd: reject drop-frame labels that counting skips Drop-frame counting never emits the first base/15 frame labels of a minute except each tenth (frames 00-01 at 29.97, 00-03 at 59.94). Accepting them let a damaged 00:01:00;00 normalize to a different start time, so fields_to_frames now rejects those labels and the start timecode is left unset. --- crates/codecs/src/mp4.rs | 51 ++++++++++++++++++++++++++------- crates/codecs/tests/mp4_rtmd.rs | 13 +++++++++ 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/crates/codecs/src/mp4.rs b/crates/codecs/src/mp4.rs index 2c1b4c59..d2e217e2 100644 --- a/crates/codecs/src/mp4.rs +++ b/crates/codecs/src/mp4.rs @@ -170,16 +170,23 @@ const RTMD_HEADER_LEN: usize = 0x12; /// sample's fixed header. The layout is proprietary and undocumented, so it is reproduced here from /// real footage; it is the same block ffmpeg reports as the `timecode` tag of an `rtmd` stream. /// -/// Fields outside SMPTE bounds are rejected (no fabricated start time from damaged metadata); -/// `base` is the timecode frame base (24, 25, 30, 60…), so a frame number must be below it. -fn rtmd_fields(sample: &[u8], base: i64) -> Option<(i64, i64, i64, i64, bool)> { +/// Fields outside SMPTE bounds — including the labels drop-frame counting skips — are rejected +/// (no fabricated start time from damaged metadata). +fn rtmd_fields(sample: &[u8], rate: FrameRate) -> Option<(i64, i64, i64, i64, bool)> { let f = sample.get(0x0d..0x12)?; let (h, m, s) = (f[0] as i64, f[1] as i64, f[2] as i64); let frames = f[4] as i64; - if h > 23 || m > 59 || s > 59 || frames >= base.max(1) { + let drop = f[3] != 0; + let base = rate.timecode_base(); + if h > 23 || m > 59 || s > 59 || frames >= base { return None; } - Some((h, m, s, frames, f[3] != 0)) + // Drop-frame counting skips the first `base / 15` frame labels of every minute except each + // tenth minute, so those labels never occur in valid footage (SMPTE ST 12-1). + if drop && rate.supports_drop_frame() && s == 0 && m % 10 != 0 && frames < base / 15 { + return None; + } + Some((h, m, s, frames, drop)) } /// Start timecode from a Sony `rtmd` track as a frame count at `rate` (issue #460), used when the @@ -193,7 +200,7 @@ fn rtmd_start_timecode(file: &Mp4File, bytes: &crate::Src, rate: FrameRate) -> O } let mut buf = [0u8; RTMD_HEADER_LEN]; bytes.read_at(s.offset, &mut buf).ok()?; - let (h, m, s, f, drop) = rtmd_fields(&buf, rate.timecode_base())?; + let (h, m, s, f, drop) = rtmd_fields(&buf, rate)?; Some(filmcraft_time::fields_to_frames(h, m, s, f, rate, drop)) } @@ -955,21 +962,45 @@ mod tests { #[test] fn rtmd_fields_parse_and_reject_damaged_metadata() { + let rate = FrameRate::FPS_59_94; // 03:37:12:34, non-drop (a Sony FX3/a6400 XAVC header). let mut s = [0u8; 24]; s[0x0d..0x12].copy_from_slice(&[3, 37, 12, 0, 34]); - assert_eq!(rtmd_fields(&s, 60), Some((3, 37, 12, 34, false))); + assert_eq!(rtmd_fields(&s, rate), Some((3, 37, 12, 34, false))); s[0x10] = 1; - assert_eq!(rtmd_fields(&s, 60), Some((3, 37, 12, 34, true))); + assert_eq!(rtmd_fields(&s, rate), Some((3, 37, 12, 34, true))); // Too short to hold the block: no fields, no panic. - assert_eq!(rtmd_fields(&[0u8; 6], 60), None); + assert_eq!(rtmd_fields(&[0u8; 6], rate), None); // Damaged fields are rejected rather than normalized into a plausible time. for bad in [[3, 37, 12, 0, 60], [3, 60, 0, 0, 0], [24, 0, 0, 0, 0], [0, 0, 60, 0, 0]] { let mut b = [0u8; 24]; b[0x0d..0x12].copy_from_slice(&bad); - assert_eq!(rtmd_fields(&b, 60), None, "{bad:?}"); + assert_eq!(rtmd_fields(&b, rate), None, "{bad:?}"); } } + + #[test] + fn rtmd_fields_reject_drop_frame_skipped_labels() { + let df = |h, m, s, f| { + let mut b = [0u8; 24]; + b[0x0d..0x12].copy_from_slice(&[h, m, s, 1, f]); + rtmd_fields(&b, FrameRate::FPS_59_94) + }; + // At 59.94 DF, frames 00-03 of a non-tenth minute are skipped, so they never occur. + assert_eq!(df(0, 1, 0, 0), None); + assert_eq!(df(0, 1, 0, 3), None); + assert_eq!(df(0, 1, 0, 4), Some((0, 1, 0, 4, true))); + assert_eq!(df(0, 10, 0, 0), Some((0, 10, 0, 0, true))); // tenth minute: label exists + assert_eq!(df(0, 1, 1, 0), Some((0, 1, 1, 0, true))); // seconds > 0: label exists + // At 29.97 DF only frames 00-01 are skipped. + let df30 = |m, s, f| { + let mut b = [0u8; 24]; + b[0x0d..0x12].copy_from_slice(&[0, m, s, 1, f]); + rtmd_fields(&b, FrameRate::FPS_29_97) + }; + assert_eq!(df30(1, 0, 0), None); + assert_eq!(df30(1, 0, 2), Some((0, 1, 0, 2, true))); + } } #[cfg(test)] diff --git a/crates/codecs/tests/mp4_rtmd.rs b/crates/codecs/tests/mp4_rtmd.rs index ae937d79..349dbb07 100644 --- a/crates/codecs/tests/mp4_rtmd.rs +++ b/crates/codecs/tests/mp4_rtmd.rs @@ -246,3 +246,16 @@ fn damaged_timecode_fields_are_not_published() { let src = filmcraft_codecs::open_bytes("bad.mp4", Arc::from(file.into_boxed_slice())).unwrap(); assert_eq!(src.info().start_timecode, None); } + +#[test] +fn drop_frame_skipped_labels_are_not_published() { + // 00:01:00;00 at 59.94 fps: frames 00-03 of a non-tenth minute don't exist in drop-frame + // counting, so this metadata is unusable rather than a start time. + let file = file_with_rtmd(&rtmd_sample(0, 1, 0, 0, 1)); + let src = filmcraft_codecs::open_bytes("df_bad.mp4", Arc::from(file.into_boxed_slice())).unwrap(); + assert_eq!(src.info().start_timecode, None); + // The first real label of that minute (frame 04) is accepted. + let file = file_with_rtmd(&rtmd_sample(0, 1, 0, 4, 1)); + let src = filmcraft_codecs::open_bytes("df_ok.mp4", Arc::from(file.into_boxed_slice())).unwrap(); + assert_eq!(src.info().start_timecode, Some(3600)); +} From 7eaba0ddd5fdf286e058eb9a75882f6d75b206f6 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 16:43:42 +0200 Subject: [PATCH 032/144] #456: Select All in the Project panel takes only the shown bin Problem: with the Project panel focused, Cmd+A ran project.selectAll, which selected every item in the project: items in closed bins and in bins of other tabs too. Fix: project.selectAll takes an optional `bin` (its direct items) and `expanded` (sub-bins twirled open in List view, whose items are shown and count). Unknown or malformed ids are an error and leave the selection alone; without `bin` it still selects the whole project. The UI passes the shown tab's bin and, in List view, the open bins; while a search filters the panel it selects exactly the rows shown. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/project_panel.rs | 46 +++++++++++++-- crates/engine/src/project_panel_tests.rs | 74 +++++++++++++++++++++++- crates/ui-egui/src/lib.rs | 12 +++- crates/ui-egui/src/panels/project.rs | 15 +++++ crates/ui-egui/tests/project_panel_ui.rs | 38 ++++++++++++ 5 files changed, 176 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/project_panel.rs b/crates/engine/src/project_panel.rs index 84659296..735f5b28 100644 --- a/crates/engine/src/project_panel.rs +++ b/crates/engine/src/project_panel.rs @@ -17,9 +17,9 @@ //! | `project.freeform.saveArrangement` / `.restoreArrangement` / `.arrangements` / `.deleteArrangement` | saved arrangements | //! | `project.freeform.options` | Freeform View Options… (grid, snap, names, durations) | //! | `project.renameBin` | rename a bin (inline rename, undoable) | -//! | `project.selectAll` / `project.deselectAll` | select every item in the project / clear the Project panel selection (Cmd+A / Cmd+Shift+A with the panel focused) | +//! | `project.selectAll` / `project.deselectAll` | select the items a bin shows (`bin`, plus the sub-bins open in List view in `expanded`; without `bin` every item in the project) / clear the Project panel selection (Cmd+A / Cmd+Shift+A with the panel focused) | -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use filmcraft_project::{Bin, BinEntry, BinId, ItemId, ItemKind, MediaRef, Project, ProjectItem, TrackKind}; use filmcraft_time::{Tick, TimeDisplay, format_time}; @@ -1171,6 +1171,43 @@ fn rename_bin(s: &mut Session, p: &Value) -> Result { Ok(json!({"bin": bin.0, "name": name})) } +/// Select All in the Project panel. With `bin`, only what a panel showing that bin lists: its +/// items, plus the items of the sub-bins named in `expanded` (bins twirled open in List view), +/// never those of a closed bin. Without `bin`, every item in the project. +fn select_all(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "project.selectAll"; + // strict: a negative or fractional `bin` is an error, not bin 0 + let id = match p.get("bin") { + None | Some(Value::Null) => { + s.state.project_selection = s.project.items.keys().copied().collect(); + return Ok(json!({"selected": s.state.project_selection.len()})); + } + Some(b) => BinId(b.as_u64().ok_or_else(|| bad(CMD, "`bin` must be a bin id"))?), + }; + let bin = s.project.root.find_bin(id).ok_or_else(|| bad(CMD, format!("no bin {}", id.0)))?; + let expanded: BTreeSet = match p.get("expanded") { + None | Some(Value::Null) => Default::default(), + Some(Value::Array(a)) => { + a.iter().map(|v| v.as_u64().map(BinId).ok_or_else(|| bad(CMD, "`expanded` must be a list of bin ids"))).collect::>()? + } + Some(_) => return Err(bad(CMD, "`expanded` must be a list of bin ids")), + }; + // iterative walk: a hostile project file cannot overflow the stack with deeply nested bins + let mut sel = Vec::new(); + let mut stack = vec![bin]; + while let Some(b) = stack.pop() { + for e in &b.children { + match e { + BinEntry::Item(i) if s.project.item(*i).is_some_and(|it| listed(&s.project, it)) => sel.push(*i), + BinEntry::Bin(sub) if expanded.contains(&sub.id) => stack.push(sub), + _ => {} + } + } + } + s.state.project_selection = sel; + Ok(json!({"bin": id.0, "selected": s.state.project_selection.len()})) +} + // ------------------------------------------------------------------------------------ registry pub(crate) fn commands() -> Vec { @@ -1223,10 +1260,7 @@ pub(crate) fn commands() -> Vec { freeform_options, ), c("project.renameBin", "Rename Bin", r#"{"bin":binId,"name":str}"#, always, rename_bin), - c("project.selectAll", "Select All Project Items", "{}", always, |s, _| { - s.state.project_selection = s.project.items.keys().copied().collect(); - Ok(json!({"selected": s.state.project_selection.len()})) - }), + c("project.selectAll", "Select All Project Items", r#"{"bin":binId?,"expanded":[binId]?}"#, always, select_all), c("project.deselectAll", "Deselect All Project Items", "{}", always, |s, _| { s.state.project_selection.clear(); Ok(Value::Null) diff --git a/crates/engine/src/project_panel_tests.rs b/crates/engine/src/project_panel_tests.rs index 42d3c79e..b094a54f 100644 --- a/crates/engine/src/project_panel_tests.rs +++ b/crates/engine/src/project_panel_tests.rs @@ -2,7 +2,7 @@ //! sorting, view presets, Freeform positions / stacks / arrangements (undoable, saved with the //! project) and bin renames. -use filmcraft_project::{ItemId, ItemKind}; +use filmcraft_project::{BinEntry, ItemId, ItemKind}; use serde_json::{Value, json}; use crate::Session; @@ -305,3 +305,75 @@ fn select_all_selects_every_project_item() { assert!(!s.state.selection.is_empty()); assert_eq!(s.state.project_selection, before); } + +#[test] +fn select_all_in_a_bin_skips_closed_bins_and_other_bins() { + // #456: Cmd+A in the Project panel selected every item in the project, closed bins included + let mut s = demo(); + let outer = s.execute("file.newBin", json!({"name": "Footage A"})).unwrap()["bin"].as_u64().unwrap(); + let inner = s.execute("file.newBin", json!({"name": "Day 1", "parent": outer})).unwrap()["bin"].as_u64().unwrap(); + let ids = media(&s); + assert!(ids.len() >= 2); + s.execute("project.moveToBin", json!({"items": [ids[0].0], "bin": outer})).unwrap(); + s.execute("project.moveToBin", json!({"items": [ids[1].0], "bin": inner})).unwrap(); + s.state.project_selection.clear(); + + // only the bin's own items: the closed sub-bin's item stays unselected + let r = s.execute("project.selectAll", json!({"bin": outer})).unwrap(); + assert_eq!(r["selected"].as_u64(), Some(1)); + assert_eq!(s.state.project_selection, vec![ids[0]]); + + // a sub-bin twirled open in List view counts + s.execute("project.selectAll", json!({"bin": outer, "expanded": [inner]})).unwrap(); + let mut sel = s.state.project_selection.clone(); + sel.sort(); + assert_eq!(sel, vec![ids[0], ids[1]]); + + // the root: its direct items only, nothing from the (closed) bins + let root = s.project.root.id; + let mut want: Vec = s + .project + .root + .children + .iter() + .filter_map(|e| if let BinEntry::Item(i) = e { Some(*i) } else { None }) + .filter(|i| s.project.item(*i).is_some_and(|it| project_panel::listed(&s.project, it))) + .collect(); + want.sort(); + s.execute("project.selectAll", json!({"bin": root.0})).unwrap(); + let mut sel = s.state.project_selection.clone(); + sel.sort(); + assert_eq!(sel, want); + assert!(!sel.contains(&ids[0]) && !sel.contains(&ids[1])); + assert!(sel.len() < s.project.items.len()); + + // an open bin inside a closed one is not shown, so it does not count either + s.execute("project.selectAll", json!({"bin": root.0, "expanded": [inner]})).unwrap(); + assert!(!s.state.project_selection.contains(&ids[1])); + // ... but open all the way down, it does + s.execute("project.selectAll", json!({"bin": root.0, "expanded": [outer, inner]})).unwrap(); + assert!(s.state.project_selection.contains(&ids[0]) && s.state.project_selection.contains(&ids[1])); +} + +#[test] +fn select_all_rejects_bad_bins_without_touching_the_selection() { + let mut s = demo(); + let ids = media(&s); + let root = s.project.root.id.0; + s.execute("project.select", json!({"items": [ids[0].0]})).unwrap(); + for p in [ + json!({"bin": 987_654_321u64}), + json!({"bin": "footage"}), + json!({"bin": -1}), + json!({"bin": root, "expanded": "all"}), + json!({"bin": root, "expanded": ["x"]}), + ] { + assert!(s.execute("project.selectAll", p.clone()).is_err(), "{p}"); + assert_eq!(s.state.project_selection, vec![ids[0]], "{p}"); + } + // unknown ids in `expanded` are simply not open bins + assert!(s.execute("project.selectAll", json!({"bin": root, "expanded": [987_654_321u64]})).is_ok()); + // `bin: null` is the same as no bin: the whole project + let r = s.execute("project.selectAll", json!({"bin": null})).unwrap(); + assert_eq!(r["selected"].as_u64(), Some(s.project.items.len() as u64)); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 857f42a5..727e80f7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1301,8 +1301,16 @@ impl FilmcraftApp { }); for mut id in fire { // Select All / Deselect All act on the Project panel's items when it has focus (#168). - if self.ui.focused == PanelKind::Project && matches!(id.as_str(), "edit.selectAll" | "edit.deselectAll") { - id = id.replacen("edit.", "project.", 1); + // Select All takes only what the shown bin lists, not the whole project (#456). + if self.ui.focused == PanelKind::Project && id == "edit.selectAll" { + let (cmd, params) = panels::project::select_all_command(self); + if let Err(e) = menus::invoke(self, ctx, &cmd, params) { + self.ui.status = e; + } + continue; + } + if self.ui.focused == PanelKind::Project && id == "edit.deselectAll" { + id = "project.deselectAll".into(); } // Mark In/Out in the Source monitor when it has focus. let params = if self.ui.focused == PanelKind::Source diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 09b04069..3493ee93 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -254,6 +254,21 @@ pub fn visible_items(app: &FilmcraftApp, inst: Inst) -> Vec { crate::panels::project_views::items_in_view(app, &v, &app.ui.project_search.to_ascii_lowercase()) } +/// The command Select All (Cmd+A) runs with the Project panel focused: the items of the shown +/// bin, plus those of sub-bins twirled open in List view (`project.selectAll {bin, expanded}`). +/// Items in closed bins and in bins of other tabs stay unselected, as in Premiere (#456). While a +/// search filters the panel, exactly the rows it shows (`project.select {items}`). +pub fn select_all_command(app: &FilmcraftApp) -> (String, Value) { + let inst = shown_inst(app); + if !app.ui.project_search.is_empty() { + let items: Vec = visible_items(app, inst).into_iter().map(|i| i.0).collect(); + return ("project.select".into(), json!({"items": items})); + } + let v = view_of(app, inst); + let expanded: &[u64] = if v.mode == ViewMode::List { &app.ui.expanded_bins } else { &[] }; + ("project.selectAll".into(), json!({"bin": v.bin.0, "expanded": expanded})) +} + // ------------------------------------------------------------------------------------ panel pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { diff --git a/crates/ui-egui/tests/project_panel_ui.rs b/crates/ui-egui/tests/project_panel_ui.rs index bb504f32..c148b58c 100644 --- a/crates/ui-egui/tests/project_panel_ui.rs +++ b/crates/ui-egui/tests/project_panel_ui.rs @@ -447,6 +447,44 @@ fn bins_open_in_place_in_a_tab_or_in_a_window() { assert_eq!(name, "Camera A"); } +#[test] +fn select_all_takes_only_the_shown_bin() { + // #456: Cmd+A selected every item in the project, closed bins and other tabs included + let mut d = project_driver(); + d.exec("project.view.set", json!({"view": "list"})); + let (bin, items) = d.footage(); + assert!(!items.is_empty()); + let sorted = |v: &[ItemId]| { + let mut v: Vec = v.iter().map(|i| i.0).collect(); + v.sort(); + v + }; + let mut footage = items.clone(); + footage.sort(); + // the root with the Footage bin closed: none of its items + d.app().ui.expanded_bins.clear(); + d.frames(2); + d.key("Cmd+A"); + let sel = d.app().session.state.project_selection.clone(); + assert!(sel.iter().all(|i| !items.contains(&i.0)), "{sel:?}"); + assert!(sel.iter().all(|i| d.app().session.project.root.children.contains(&BinEntry::Item(*i))), "{sel:?}"); + // twirled open in List view, its items are shown and count + d.app().ui.expanded_bins.push(bin); + d.frames(2); + d.key("Cmd+A"); + let sel = d.app().session.state.project_selection.clone(); + assert!(items.iter().all(|i| sel.contains(&ItemId(*i))), "{sel:?}"); + d.key("Cmd+Shift+A"); + assert!(d.app().session.state.project_selection.is_empty()); + // the bin opened in its own tab: exactly its items + d.app().ui.expanded_bins.clear(); + d.app().session.prefs.general.bins_double_click = "openNewTab".into(); + double_click_bin(&mut d, bin, false); + assert_eq!(d.app().ui.project_panel.active_tab, Some(0)); + d.key("Cmd+A"); + assert_eq!(sorted(&d.app().session.state.project_selection), footage); +} + #[test] fn footer_buttons_are_wired() { let mut d = project_driver(); From b6ba78e30057912c0ec6bbb0ca7d98d21a63cc20 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 16:57:20 +0200 Subject: [PATCH 033/144] #412: select a single keyframe in Effect Controls Problem: the keyframe lane highlighted every keyframe whose time equals the playhead. A click on a keyframe moves the playhead there, so every parameter's keyframe at that frame lit up (point 4 of #412). Fix: a serde keyframe selection in the UI state (clip, effect index, param, mask, media time). A click (or drag start) selects exactly that keyframe and still moves the playhead; highlighting reads the selection. A moved keyframe stays selected; references to deleted keyframes or clips no longer shown are dropped. Readable via ui.inspect, settable via ui.set keyframeSelection. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/control.rs | 8 +++++ crates/ui-egui/src/panels/effect_controls.rs | 25 ++++++++++++++- crates/ui-egui/src/state.rs | 17 ++++++++++ crates/ui-egui/tests/keyframes_ui.rs | 33 ++++++++++++++++++++ docs/control-protocol.md | 2 +- 5 files changed, 83 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 16260258..6ebc1158 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -193,6 +193,14 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) if let Some(q) = s("effectsSearch") { app.ui.effects_search = q.to_string(); } + // Effect Controls keyframe selection (`[{clip, effect, param, mask?, time}]`), replaced; + // references to missing keyframes are dropped by the panel + if let Some(v) = p.get("keyframeSelection") { + match serde_json::from_value(v.clone()) { + Ok(sel) => app.ui.keyframe_selection = sel, + Err(e) => return err(format!("`keyframeSelection`: {e}")), + } + } if let Some(v) = p.get("safeMargins").and_then(Value::as_bool) { app.ui.program.safe_margins = v; } diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 1eeb87b2..3a595010 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -9,6 +9,7 @@ use serde_json::{Value, json}; use crate::FilmcraftApp; use crate::icons::{self, Icon}; +use crate::state::KeyframeRef; use crate::theme::Tokens; const ROW_H: f32 = 22.0; @@ -41,6 +42,16 @@ fn graph_drag_offset(ui: &egui::Ui, response: &egui::Response, previous: egui::V } } +/// Whether `k` names a keyframe of the clip `it` (its effect, parameter and media time). +fn keyframe_exists(it: &TrackItem, k: &KeyframeRef) -> bool { + let Some(e) = it.effects.get(k.effect) else { return false }; + let param = match k.mask { + Some(m) => e.masks.get(m).and_then(|m| m.param(&k.param)), + None => e.params.get(&k.param), + }; + param.is_some_and(|p| p.keyframes.iter().any(|kf| kf.time == k.time)) +} + fn selected_clip(app: &FilmcraftApp) -> Option<(ClipId, TrackItem, TrackKind)> { selected_clips(app).into_iter().next() } @@ -71,6 +82,8 @@ fn selected_clips(app: &FilmcraftApp) -> Vec<(ClipId, TrackItem, TrackKind)> { pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let clips = selected_clips(app); + // keep only selected keyframes that still exist on a clip shown here + app.ui.keyframe_selection.retain(|k| clips.iter().any(|(c, it, _)| c.0 == k.clip && keyframe_exists(it, k))); let Some((clip, it, _)) = clips.first().cloned() else { // a transition clicked in the Timeline (#430) if let Some(id) = crate::panels::transition_controls::selected(app) { @@ -561,7 +574,13 @@ pub(crate) fn param_row( let kr = Rect::from_center_size(pos2(kx, y), vec2(11.0, 11.0)); let resp = ui.interact(kr.expand(2.0), id, Sense::click_and_drag()); app.auto.add(&format!("effectControls.{}.{}.keyframe.{}", e.effect, pkey, k.time.0), kr, "keyframe"); - let sel = k.time == mt || resp.dragged(); + // highlighted when selected (not merely under the playhead: that lit up every + // parameter's keyframe at the same time, #412) + let is_this = |s: &KeyframeRef| s.clip == clip.0 && s.effect == idx && s.mask == mask && s.param == pd.id && s.time == k.time; + if resp.clicked() || resp.drag_started() { + app.ui.keyframe_selection = vec![KeyframeRef { clip: clip.0, effect: idx, param: pd.id.to_string(), mask, time: k.time }]; + } + let sel = resp.dragged() || app.ui.keyframe_selection.iter().any(is_this); let col = if sel { t.hot_text } else { Color32::from_rgb(0xb0, 0xb0, 0xb0) }; match k.interp { filmcraft_project::Interpolation::Hold => { @@ -583,6 +602,10 @@ pub(crate) fn param_row( rate.snap_nearest(it.start + Tick(((f + off / lane.width()) as f64 * dur) as i64)).clamp(it.start, it.end() - rate.frame_duration()); let new_media = it.source_in + Tick(((new_tl - it.start).0 as f64 * it.speed.abs()) as i64); if new_media != k.time { + // the moved keyframe stays selected + for s in app.ui.keyframe_selection.iter_mut().filter(|s| is_this(s)) { + s.time = new_media; + } actions.push(( "effects.moveKeyframe".into(), with_mask(json!({"clip": clip.0, "effect": eff_json, "param": pd.id, "mediaTime": k.time.0, "to": new_media.0})), diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index aacea877..74ca44e1 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -368,6 +368,19 @@ pub struct Eyedropper { pub mask: Option, } +/// A keyframe selected in the Effect Controls keyframe lane: clip, effect index, parameter (of an +/// effect mask when `mask` is set) and the keyframe's media time. The panel drops references that +/// no longer match a keyframe (deleted, moved, another clip selected). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct KeyframeRef { + pub clip: u64, + pub effect: usize, + pub param: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mask: Option, + pub time: filmcraft_time::Tick, +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct UiState { #[serde(default)] @@ -408,6 +421,9 @@ pub struct UiState { /// monitor (Esc or a click elsewhere disarms it). Never saved. #[serde(skip)] pub eyedropper: Option, + /// Effect Controls: the selected keyframes, highlighted in the keyframe lane (a click selects one). + #[serde(default)] + pub keyframe_selection: Vec, /// Essential Sound sub-tab: "Edit" or "Browse". #[serde(default)] pub essential_sound_tab: String, @@ -904,6 +920,7 @@ impl Default for UiState { show_scopes: false, status: String::new(), eyedropper: None, + keyframe_selection: Vec::new(), essential_sound_tab: "Edit".into(), tts: Default::default(), export: Default::default(), diff --git a/crates/ui-egui/tests/keyframes_ui.rs b/crates/ui-egui/tests/keyframes_ui.rs index 630a8cea..c97cd506 100644 --- a/crates/ui-egui/tests/keyframes_ui.rs +++ b/crates/ui-egui/tests/keyframes_ui.rs @@ -225,3 +225,36 @@ fn effect_controls_ruler_moves_the_playhead() { assert!(d.find("effectControls.playhead").is_none(), "no handle while the playhead is past the clip"); d.rect("effectControls.ruler"); } + +/// #412: Scale and Position both have a keyframe at the same time. A click on Scale's keyframe in +/// the Effect Controls lane selects that keyframe only (every keyframe under the playhead, which +/// the click moves there, used to light up); a keyframe that is gone drops out of the selection. +#[test] +fn clicking_a_lane_keyframe_selects_only_that_keyframe() { + let (mut d, clip) = Driver::demo(); + let t0 = d.seek(1.0); + for param in ["scale", "position"] { + d.exec("effects.addKeyframe", json!({"clip": clip.id, "effect": "motion", "param": param})); + } + let time = d.scale(&clip).0[0]; + let motion = { + let s = &d.harness.state().session; + let it = s.active_sequence().unwrap().find_item(ClipId(clip.id)).unwrap().1; + it.effects.iter().position(|e| e.effect == "motion").unwrap() + }; + let selection = |d: &mut Driver| d.ok("ui.inspect", json!({}))["ui"]["keyframe_selection"].clone(); + d.seek(2.0); + assert_eq!(selection(&mut d), json!([]), "nothing selected yet"); + + d.click(&format!("effectControls.motion.scale.keyframe.{time}")); + assert_eq!(selection(&mut d), json!([{"clip": clip.id, "effect": motion, "param": "scale", "time": time}]), "only Scale's keyframe"); + assert_eq!(d.playhead(), t0, "the click still moves the playhead to the keyframe"); + d.shot("keyframes-select-one"); + + d.click(&format!("effectControls.motion.position.keyframe.{time}")); + assert_eq!(selection(&mut d), json!([{"clip": clip.id, "effect": motion, "param": "position", "time": time}]), "a click replaces the selection"); + + // the diamond removes the keyframe at the playhead, the selected one: the selection empties + d.click("effectControls.motion.position.addKeyframe"); + assert_eq!(selection(&mut d), json!([]), "a deleted keyframe is not selected"); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 9cf58cf6..841a8275 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -29,7 +29,7 @@ Methods (handlers in `crates/ui-egui/src/control.rs`): | `engine.commands` / `ui.menu.list` | – | command registry / menu tree | | `ui.inspect` | – | UI state (tool, workspace, dock, timeline view, playback, fps…) | | `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:{…}}` | `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.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 | From b24225afd378e825b82f62ebb81a498d4b42c76e Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 16:58:26 +0200 Subject: [PATCH 034/144] #642: open Help/About links in the system browser on the desktop Problem: every link (Help menu, About dialog, header Discord button, Import screen, Settings and Keyboard help) went through egui's `ctx.open_url`. The workspace builds eframe without its `links` feature, so egui-winit only logs "Cannot open url - feature links not enabled" and nothing opens on Linux, Windows or macOS. Fix: add a `HostHooks::open_url` hook, as PhotoCraft does. `links::open` now takes the app and calls the hook, falling back to `ctx.open_url` when it is unset or fails (the web build keeps opening a new tab). The desktop app sets it to `open::that_detached` (open 5, MIT), which does not block the UI thread. Test: every Help link reaches the hook, and a failing hook falls back to `ctx.open_url`. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 30 ++++++++++++++++ apps/filmcraft/Cargo.toml | 2 ++ apps/filmcraft/src/main.rs | 4 +++ crates/ui-egui/src/header.rs | 2 +- crates/ui-egui/src/lib.rs | 4 +++ crates/ui-egui/src/links.rs | 44 ++++++++++++++++++++++-- crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/panels/dialogs.rs | 2 +- crates/ui-egui/src/panels/import_mode.rs | 2 +- crates/ui-egui/src/panels/keyboard.rs | 2 +- crates/ui-egui/src/panels/settings.rs | 2 +- 11 files changed, 88 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d94a4457..6e6f3fb0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1685,6 +1685,7 @@ dependencies = [ "objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", + "open", "rfd", "serde_json", "sys-locale", @@ -3231,6 +3232,25 @@ dependencies = [ "generic-array", ] +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "itertools" version = "0.15.0" @@ -4156,6 +4176,16 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "open" +version = "5.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" +dependencies = [ + "is-wsl", + "libc", +] + [[package]] name = "openssl-probe" version = "0.2.1" diff --git a/apps/filmcraft/Cargo.toml b/apps/filmcraft/Cargo.toml index cf1cfff5..ee774c86 100644 --- a/apps/filmcraft/Cargo.toml +++ b/apps/filmcraft/Cargo.toml @@ -34,6 +34,8 @@ rfd = { workspace = true } cpal = "0.16" chrono = { version = "0.4", default-features = false, features = ["clock"] } sys-locale = "0.3" +# Help menu / About / Discord links in the system browser (MIT; as PhotoCraft). +open = "5" [dev-dependencies] glob = "0.3" diff --git a/apps/filmcraft/src/main.rs b/apps/filmcraft/src/main.rs index 8e9668d6..96a12a78 100644 --- a/apps/filmcraft/src/main.rs +++ b/apps/filmcraft/src/main.rs @@ -237,6 +237,10 @@ fn main() -> eframe::Result { app.hooks.pick_folder_at = Some(Box::new(|dir: &str| rfd::FileDialog::new().set_directory(dir).pick_folder().map(|p| p.to_string_lossy().into_owned()))); app.hooks.open_path = Some(Box::new(open_path)); + // Help menu, About and Discord links (#642): eframe is built without its `links` + // feature, so `ctx.open_url` does nothing on the desktop. Detached, so a launcher + // that waits for the browser never freezes the UI. + app.hooks.open_url = Some(Box::new(|url: &str| open::that_detached(url).map_err(|e| e.to_string()))); // Settings ▸ General ▸ Interface Language ▸ System Language (#218). app.hooks.cursor_screen_position = Some(Box::new(filmcraft_platform::cursor::cursor_screen_position)); app.hooks.system_languages = Some(Box::new(|| sys_locale::get_locales().collect())); diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index e3ccfda2..721e35cf 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -147,7 +147,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } if resp.clicked() { - crate::links::open(ui.ctx(), crate::links::DISCORD); + crate::links::open(app, ui.ctx(), crate::links::DISCORD); } } let popup_id = egui::Id::new("workspaces-popup"); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 857f42a5..e5a9b1b8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -139,6 +139,10 @@ pub struct HostHooks { /// Open a file in its default application, or (`true`) reveal it in the file manager (Edit ▸ /// Edit Original, Help ▸ Reveal Log Files). pub open_path: Option Result<(), String>>>, + /// Open a URL in the system browser (Help menu links, About dialog, Discord button, Import + /// screen). Without it, or when it fails, links go through `egui::Context::open_url`, which + /// opens a new tab on the web (the native egui backend is built without its `links` feature). + pub open_url: Option Result<(), String>>>, /// The operating system's light or dark appearance when egui cannot report it (Linux desktops /// whose Wayland compositor sends no theme to winit). Without it, or without an answer, Auto /// uses `egui::Context::system_theme`. diff --git a/crates/ui-egui/src/links.rs b/crates/ui-egui/src/links.rs index 641463d5..6d980e6c 100644 --- a/crates/ui-egui/src/links.rs +++ b/crates/ui-egui/src/links.rs @@ -31,8 +31,17 @@ pub fn url_for(command: &str) -> Option<&'static str> { ALL.iter().find(|(id, _, _)| *id == command).map(|(_, _, u)| *u) } -/// Open `url` in the system browser (a new tab on the web). -pub fn open(ctx: &egui::Context, url: &str) { +/// Open `url` in the system browser (a new tab on the web). The desktop host's +/// [`HostHooks::open_url`](crate::HostHooks::open_url) opens it; without the hook, or when it +/// fails, it goes through `ctx.open_url`, which only does something on the web: the native egui +/// backend is built without its `links` feature, so there it would only log (#642). +pub fn open(app: &mut crate::FilmcraftApp, ctx: &egui::Context, url: &str) { + if let Some(f) = app.hooks.open_url.as_mut() { + match f(url) { + Ok(()) => return, + Err(e) => log::warn!("can't open {url} in the browser: {e}"), + } + } ctx.open_url(egui::OpenUrl::new_tab(url)); } @@ -48,4 +57,35 @@ mod tests { assert_eq!(url_for("help.discord"), Some(DISCORD)); assert_eq!(url_for("help.nope"), None); } + + #[test] + fn links_open_through_the_host_hook() { + // #642: on the desktop every link (Help menu, About, Discord button, Import screen) must + // reach the host's browser opener; `ctx.open_url` alone is a no-op natively. + use std::cell::RefCell; + use std::rc::Rc; + let opened: Rc>> = Rc::default(); + let rec = opened.clone(); + let mut app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); + app.hooks.open_url = Some(Box::new(move |u: &str| { + rec.borrow_mut().push(u.to_string()); + Ok(()) + })); + let ctx = egui::Context::default(); + for (id, _, url) in ALL { + let r = crate::menus::invoke(&mut app, &ctx, id, serde_json::json!({})).unwrap(); + assert_eq!(r["url"], url, "{id}"); + assert_eq!(opened.borrow().last().map(String::as_str), Some(url), "{id}"); + } + open(&mut app, &ctx, DISCORD); + assert_eq!(opened.borrow().last().map(String::as_str), Some(DISCORD)); + assert_eq!(opened.borrow().len(), ALL.len() + 1); + + // A failing opener falls back to `ctx.open_url` instead of losing the click. + app.hooks.open_url = Some(Box::new(|_: &str| Err("no browser".into()))); + open(&mut app, &ctx, ISSUES); + let fallback: Vec = + ctx.output(|o| o.commands.iter().filter_map(|c| if let egui::OutputCommand::OpenUrl(u) = c { Some(u.url.clone()) } else { None }).collect()); + assert_eq!(fallback, [ISSUES]); + } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9dff12d3..82b25306 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -404,7 +404,7 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: } id if crate::links::url_for(id).is_some() => { let url = crate::links::url_for(id).unwrap_or_default(); - crate::links::open(ctx, url); + crate::links::open(app, ctx, url); app.ui.status = tlf!("Opened {url}", url); return Ok(json!({"url": url})); } diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index eed8fd28..38d2af0e 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -471,7 +471,7 @@ fn about_tab(app: &mut FilmcraftApp, ui: &mut egui::Ui) { fg, ); if resp.clicked() { - links::open(ui.ctx(), url); + links::open(app, ui.ctx(), url); } }; link(ui, "discord", Icon::Chat, tl!("Join the ArtCraft Discord"), links::DISCORD, true); diff --git a/crates/ui-egui/src/panels/import_mode.rs b/crates/ui-egui/src/panels/import_mode.rs index 5d78e93e..ef68c0aa 100644 --- a/crates/ui-egui/src/panels/import_mode.rs +++ b/crates/ui-egui/src/panels/import_mode.rs @@ -67,7 +67,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { crate::icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 20.0, r.center().y), vec2(16.0, 16.0)), icon, fg); ui.painter().galley(pos2(r.min.x + 34.0, r.center().y - g.size().y / 2.0), g, fg); if resp.clicked() { - crate::links::open(&ctx, url); + crate::links::open(app, &ctx, url); } x = r.max.x + 12.0; } diff --git a/crates/ui-egui/src/panels/keyboard.rs b/crates/ui-egui/src/panels/keyboard.rs index 674769d6..dfc960aa 100644 --- a/crates/ui-egui/src/panels/keyboard.rs +++ b/crates/ui-egui/src/panels/keyboard.rs @@ -210,7 +210,7 @@ pub fn route(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: &Val } "graphics.beginTextEditing" => begin_text_editing(app), "help.filmcraftHelp" => { - crate::links::open(ctx, HELP_URL); + crate::links::open(app, ctx, HELP_URL); Ok(json!({"url": HELP_URL})) } "app.quit" => { diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 41395d86..fd2d7b8d 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -739,7 +739,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { ui.horizontal(|ui| { ui.add_space(20.0); if button(app, ui, "settings.help", tl!("Help"), false) { - crate::links::open(ctx, &format!("{}/blob/main/docs/project-files.md#settings", crate::links::GITHUB)); + crate::links::open(app, ctx, &format!("{}/blob/main/docs/project-files.md#settings", crate::links::GITHUB)); } if button(app, ui, "settings.reset", tl!("Reset…"), false) { // this category back to its defaults (OK applies it) From 170852d8db58dec0cc42b8624345569203de0a78 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 02:00:58 +1100 Subject: [PATCH 035/144] #533: Preserve explicit export sample rates to avoid silent substitution. --- crates/engine/src/export_tests.rs | 15 ++++++++++ crates/export/src/settings.rs | 11 +------- crates/export/src/settings_tests.rs | 44 +++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/export_tests.rs b/crates/engine/src/export_tests.rs index 2757ebba..cf514b49 100644 --- a/crates/engine/src/export_tests.rs +++ b/crates/engine/src/export_tests.rs @@ -64,6 +64,21 @@ fn export_integer_parameters_cannot_wrap_or_overflow() { assert!(s.execute("export.resolve", json!({"bitrateKbps":8000.0, "keyframeDistance":48.0})).is_ok(), "integer-valued floats are integers"); } +#[test] +fn wav_resolve_honours_explicit_sample_rates() { + let mut s = demo(); + for rate in [4000, 192_000, 192_001, 200_000, 352_800, 384_000] { + let result = s.execute("export.resolve", json!({"format":"wav", "settings":{"audio":{"sample_rate":rate}}})).unwrap(); + assert_eq!(result["settings"]["audio"]["sample_rate"], rate); + assert_eq!(result["output"]["sampleRate"], rate); + } + let result = s.execute("export.resolve", json!({"format":"wav", "settings":{"audio":{"sample_rate":null}}})).unwrap(); + assert_eq!(result["output"]["sampleRate"], 48_000); + for rate in [0, 384_001] { + assert!(s.execute("export.resolve", json!({"format":"wav", "settings":{"audio":{"sample_rate":rate}}})).is_err()); + } +} + #[test] fn nested_camel_case_settings_are_honoured() { let mut s = demo(); diff --git a/crates/export/src/settings.rs b/crates/export/src/settings.rs index 8e8fc7ef..a0a083cd 100644 --- a/crates/export/src/settings.rs +++ b/crates/export/src/settings.rs @@ -406,16 +406,7 @@ impl ExportSettings { 6.. => 6, _ => 2, }; - Resolved { - width: w, - height: h, - rate, - sample_rate: self.audio.sample_rate.filter(|r| (8000..=192_000).contains(r)).unwrap_or(seq_sr), - channels, - target_kbps: target, - max_kbps: max, - keyint, - } + Resolved { width: w, height: h, rate, sample_rate: self.audio.sample_rate.unwrap_or(seq_sr), channels, target_kbps: target, max_kbps: max, keyint } } /// The audio codec actually used. diff --git a/crates/export/src/settings_tests.rs b/crates/export/src/settings_tests.rs index b494226b..d2410ae6 100644 --- a/crates/export/src/settings_tests.rs +++ b/crates/export/src/settings_tests.rs @@ -167,6 +167,50 @@ fn wav_and_aiff_audio_only() { } } +#[test] +fn wav_export_honours_explicit_sample_rates() { + let (project, sequence, sources) = matte([0.0, 0.0, 0.0, 1.0], 64, 36, Some(-6.0)); + let dir = Scratch::new("wav-sample-rates"); + for rate in [1, 4000, 7350, 8000, 48_000, 192_000, 192_001, 200_000, 352_800, 384_000] { + let settings = ExportSettings { + format: Format::Wav, + path: dir.path(&format!("{rate}.wav")), + audio: AudioSettings { sample_rate: Some(rate), ..Default::default() }, + range: Some(TimeRange::new(Tick::ZERO, Tick(TICKS_PER_SECOND / 4))), + ..Default::default() + }; + settings.validate().unwrap(); + let resolved = settings.resolve(64, 36, FrameRate::FPS_24, 48_000); + assert_eq!(resolved.sample_rate, rate, "requested {rate} Hz"); + export(&project, sequence, &settings, &sources, &Progress::default()).unwrap(); + let bytes = std::fs::read(&settings.path).unwrap(); + assert_eq!(u32::from_le_bytes(bytes[24..28].try_into().unwrap()), rate); + assert_eq!(u32::from_le_bytes(bytes[40..44].try_into().unwrap()), rate / 4 * 4, "quarter-second stereo 16-bit PCM"); + if let Some(probe) = ffprobe_json(&["-show_streams"], &settings.path) { + assert_eq!(probe["streams"][0]["sample_rate"], rate.to_string()); + } + } + let settings = ExportSettings { format: Format::Wav, ..Default::default() }; + assert_eq!(settings.resolve(64, 36, FrameRate::FPS_24, 44_100).sample_rate, 44_100); +} + +#[test] +fn aac_export_rejects_unsupported_explicit_sample_rates() { + let (project, sequence, sources) = matte([0.0, 0.0, 0.0, 1.0], 64, 36, Some(-6.0)); + let dir = Scratch::new("aac-sample-rates"); + for rate in [4000, 192_001, 200_000, 384_000] { + let settings = ExportSettings { + path: dir.path(&format!("{rate}.mp4")), + audio: AudioSettings { sample_rate: Some(rate), ..Default::default() }, + range: Some(TimeRange::new(Tick::ZERO, Tick(TICKS_PER_SECOND / 4))), + ..Default::default() + }; + let error = export(&project, sequence, &settings, &sources, &Progress::default()).unwrap_err(); + assert!(error.to_string().contains("sample rate"), "{error}"); + assert!(!Path::new(&settings.path).exists(), "an unsupported rate must not produce a substitute file"); + } +} + #[test] fn invalid_export_allocations_are_rejected_before_rendering() { let (project, sequence, _) = matte([1.0, 0.0, 0.0, 1.0], 64, 36, None); From 4ce807263db0bdf11acc029ecdf243d80a7e69c3 Mon Sep 17 00:00:00 2001 From: JordyUSA <75485565+JordyUSA@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:06:45 +0700 Subject: [PATCH 036/144] Project panel test: drop an unneeded mut (clippy unused_mut) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/tests/project_panel_ui.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/tests/project_panel_ui.rs b/crates/ui-egui/tests/project_panel_ui.rs index a56b8928..53d30b2e 100644 --- a/crates/ui-egui/tests/project_panel_ui.rs +++ b/crates/ui-egui/tests/project_panel_ui.rs @@ -718,7 +718,7 @@ fn double_click_imports_with_audio_in_the_timeline() { 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, snapshots: None }; d.frames(10); - let mut double_click = |d: &mut Driver, at: egui::Pos2| { + let double_click = |d: &mut Driver, at: egui::Pos2| { let push = |d: &mut Driver, e: egui::Event| d.harness.input_mut().events.push(e); d.frames(60); push(d, egui::Event::PointerMoved(at)); From 69bb1dd33cbbf262e9fd70bc822b584a024a5a74 Mon Sep 17 00:00:00 2001 From: MrCloudy2 Date: Sat, 10 Oct 2026 17:14:34 +0200 Subject: [PATCH 037/144] #480: exports no longer stop for good when a clip's decoding uses rayon A GopCache decodes with its lock held, and that decoding starts rayon work (rotating iPhone frames, cropping, slice-threaded decoders). On the global pool a worker that took part of it could, waiting inside it, pick up another render job asking the same cache for a frame and block on the lock, while the lock holder waited for the work on that worker's stack: exports of rotated iPhone HEVC clips stopped making frames in about half the runs. The locked decoding now runs on a separate decode pool, whose threads never wait for a cache's lock; a caller that is a rayon worker waits on a plain thread so it does no other rayon work while holding the lock. The cancel/catch-up/draft/ background hints travel with it (filmcraft_media::cancel::Context). Intra-only streams crop and rotate before taking the lock again. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + crates/codecs/Cargo.toml | 1 + crates/codecs/src/gop.rs | 161 ++++++++++++++++++++++++++++++++++--- crates/media/src/cancel.rs | 54 +++++++++++++ 4 files changed, 208 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 188823bc..8c993ca7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1819,6 +1819,7 @@ dependencies = [ "filmcraft-vp9", "image", "log", + "rayon", "serde_json", "symphonia", "thiserror 2.0.21", diff --git a/crates/codecs/Cargo.toml b/crates/codecs/Cargo.toml index 3a490e2c..7caf5eb9 100644 --- a/crates/codecs/Cargo.toml +++ b/crates/codecs/Cargo.toml @@ -31,6 +31,7 @@ filmcraft-av1 = { workspace = true } filmcraft-apv = { workspace = true } filmcraft-aac = { workspace = true } filmcraft-opus = { workspace = true } +rayon = { workspace = true } thiserror = { workspace = true } log = { workspace = true } # Decode timing counters (`gop_stats`); std's Instant panics on wasm32. diff --git a/crates/codecs/src/gop.rs b/crates/codecs/src/gop.rs index b23b130e..4f442887 100644 --- a/crates/codecs/src/gop.rs +++ b/crates/codecs/src/gop.rs @@ -109,8 +109,9 @@ impl std::ops::Sub for GopStats { } } -/// A container's video sample table, in decode (file) order. -pub trait VideoSamples { +/// A container's video sample table, in decode (file) order. `Sync`: a cache decodes on its own +/// threads ([`isolated`]). +pub trait VideoSamples: Sync { fn count(&self) -> usize; /// Presentation timestamp of sample `i` (track units). fn pts(&self, i: usize) -> i64; @@ -425,6 +426,57 @@ const MIN_FRAMES: usize = 64; /// rather than restarting at the sync sample. const CONTINUE_THROUGH: usize = 48; +/// Threads that run the caches' decoding (see [`isolated`]); `None` where none can be made. +#[cfg(not(target_arch = "wasm32"))] +fn decode_pool() -> Option<&'static rayon::ThreadPool> { + static POOL: std::sync::OnceLock> = std::sync::OnceLock::new(); + POOL.get_or_init(|| { + let n = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1); + rayon::ThreadPoolBuilder::new().num_threads(n).thread_name(|i| format!("decode-{i}")).build().ok() + }) + .as_ref() +} + +/// Run a cache's decoding, its lock held by the caller, on [`decode_pool`], the caller waiting +/// without doing other rayon work meanwhile (#480). +/// +/// That decoding starts rayon work (a decoder's slices, cropping and rotating the frames). On the +/// pool of the render jobs that ask the cache for frames, a worker that took part of it could, +/// waiting inside it, pick up another of those jobs and block on the lock: the lock holder then +/// waits for its work on the blocked worker's stack, and that worker for the lock, for good (an +/// export of rotated iPhone clips stopped making frames). On the decode pool that work only ever +/// runs beside other caches' decoding, which never waits for a cache's lock. A caller that is a +/// rayon worker would also run other jobs of its pool while it waits for the decode pool, some +/// of them asking for a cache's lock while it holds this one: it waits on a plain thread instead. +fn isolated(f: impl FnOnce() -> crate::Result + Send) -> crate::Result { + let ctx = filmcraft_media::cancel::Context::current(); + let job = move || ctx.run(f); + #[cfg(not(target_arch = "wasm32"))] + if let Some(pool) = decode_pool() { + if rayon::current_thread_index().is_none() { + return pool.install(job); + } + let slot = Mutex::new(Some(job)); + let waited = std::thread::scope(|scope| { + let spawned = std::thread::Builder::new().name("decode-wait".into()).spawn_scoped(scope, || { + let job = slot.lock().unwrap_or_else(PoisonError::into_inner).take(); + job.map(|job| pool.install(job)) + }); + spawned.map(|h| h.join()) + }); + return match waited { + Ok(Ok(Some(r))) => r, + Ok(Err(_)) => Err(CodecError::Decode("internal error while decoding".into())), + // no thread to wait on: decode here, as before + Ok(Ok(None)) | Err(_) => match slot.into_inner().unwrap_or_else(PoisonError::into_inner) { + Some(job) => job(), + None => Err(CodecError::Decode("internal error while decoding".into())), + }, + }; + } + job() +} + impl GopCache { pub fn new(explicit_color: Option) -> Self { // unit tests count decoder restarts: each of their caches gets a pool of its own @@ -482,7 +534,11 @@ impl GopCache { } fn store(&self, st: &mut State, pts: i64, f: VideoFrame, draft: bool) { - let f = self.finish(f); + self.store_finished(st, pts, self.finish(f), draft); + } + + /// 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(); if draft { st.drafts.insert(pts); @@ -604,6 +660,22 @@ impl GopCache { if st.intra { return self.intra_frame(st, s, i, want_pts); } + // with the lock held: see `isolated` + let st = &mut *st; + isolated(|| self.decode_until(st, s, i, want_pts, late_before, draft)) + } + + /// Decode (the lock held) until the frame with `want_pts` (sample `i`) is cached; return it. + fn decode_until( + &self, + st: &mut State, + s: &dyn VideoSamples, + i: usize, + want_pts: i64, + late_before: Option, + draft: bool, + ) -> crate::Result> { + let n = s.count(); let mut key = s.sync_before(i); // Continue the running decoder when it has passed the wanted sample's sync sample and // either has not reached the sample yet or has been fed it without outputting it yet @@ -656,14 +728,14 @@ impl GopCache { let out = timed(|| decoder.decode(&data, s.pts(k)))?; st.next += 1; DECODED.fetch_add(1, Ordering::Relaxed); - self.store_output(&mut st, out); + self.store_output(st, out); if st.cached(want_pts, draft).is_some() { break; } } if st.cached(want_pts, draft).is_none() { let out = st.decoder.as_mut().map(|d| timed(|| d.flush())).unwrap_or_default(); - self.store_output(&mut st, out); + self.store_output(st, out); st.next = usize::MAX; } // nearest decoded frame at or before the wanted pts (robust to decoder pts quirks) @@ -748,14 +820,17 @@ impl GopCache { DECODED.fetch_add(1, Ordering::Relaxed); timed(|| dec.decode(&data, want_pts)) }); + // crop and rotate before taking the lock: that work runs on rayon (see `isolated`) + let res = res.map(|out| { + count_frames(&out); + out.into_iter().map(|d| (d.pts, self.finish(d.frame), d.draft)).collect::>() + }); let mut st = self.shared.state.lock().unwrap_or_else(|e| e.into_inner()); if st.spare.len() < 16 { st.spare.push(dec); } - let res = res?; - count_frames(&res); - for d in res { - self.store(&mut st, d.pts, d.frame, d.draft); + for (pts, frame, draft) in res? { + self.store_finished(&mut st, pts, frame, draft); } st.at_or_before(want_pts, true).ok_or_else(|| CodecError::Decode("frame not produced".into())) } @@ -887,6 +962,74 @@ mod tests { assert!(s.decodes.load(Ordering::Relaxed) <= 60 + 24 + 1); } + /// A decoder that, like a slice-threaded one, splits each picture's work over rayon. + struct ParDec(Box); + + impl VideoDecoder for ParDec { + fn decode(&mut self, sample: &[u8], pts: i64) -> crate::Result> { + use rayon::prelude::*; + let rows: u64 = + (0..64u32).into_par_iter().with_max_len(1).map(|r| (0..20_000u64).fold(u64::from(r), |a, x| a.wrapping_mul(31).wrapping_add(x))).sum(); + std::hint::black_box(rows); + self.0.decode(sample, pts) + } + fn flush(&mut self) -> Vec { + self.0.flush() + } + fn reset(&mut self) { + self.0.reset() + } + fn name(&self) -> &str { + "par" + } + } + + struct ParSamples(Samples); + + impl VideoSamples for ParSamples { + fn count(&self) -> usize { + self.0.count() + } + fn pts(&self, i: usize) -> i64 { + self.0.pts(i) + } + fn sync_before(&self, i: usize) -> usize { + self.0.sync_before(i) + } + fn sample_at(&self, t: i64) -> Option { + self.0.sample_at(t) + } + fn read(&self, i: usize) -> crate::Result> { + self.0.read(i) + } + fn make_decoder(&self) -> crate::Result> { + Ok(Box::new(ParDec(self.0.make_decoder()?))) + } + } + + /// #480: render jobs on rayon asking one source for frames while its decoding (a decoder's + /// slices, rotating the frames) runs on rayon too. A worker that took part of that work and, + /// waiting inside it, picked up another frame request blocked on the cache's lock for good: + /// an export of rotated iPhone clips stopped making frames. + #[test] + fn rayon_frame_requests_while_decoding_on_rayon_never_deadlock() { + use rayon::prelude::*; + let s = Arc::new(ParSamples(samples(240, 30, 4, false))); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + for round in 0..40usize { + // export-like: many frame workers, frames of several GOPs of a fresh source at once + let c = GopCache::new(None).with_rotation(1); + (0..96usize).into_par_iter().with_max_len(1).for_each(|k| { + let i = (k * 37 + round * 53) % 240; + assert_eq!(index_of(&c.frame(&*s, i as i64 * 1000).expect("frame")), i); + }); + } + let _ = tx.send(()); + }); + assert!(rx.recv_timeout(Duration::from_secs(120)).is_ok(), "frame requests deadlocked"); + } + #[test] fn running_decoder_continues_through_a_nearby_sync_sample() { let s = samples(200, 30, 0, false); diff --git a/crates/media/src/cancel.rs b/crates/media/src/cancel.rs index bfb391dd..5c2b5f28 100644 --- a/crates/media/src/cancel.rs +++ b/crates/media/src/cancel.rs @@ -96,10 +96,64 @@ pub fn cancelled() -> bool { CURRENT.with(|c| c.borrow().as_ref().is_some_and(|f| f.load(Ordering::Relaxed))) } +/// The hints of the work running on this thread ([`with_cancel`], [`with_catch_up`], +/// [`with_draft`], [`with_background`]), to carry them to another thread doing part of that work. +#[derive(Clone, Default)] +pub struct Context { + cancel: Option>, + catch_up: Option, + draft: bool, + background: bool, +} + +impl Context { + /// This thread's hints. + pub fn current() -> Self { + Context { cancel: CURRENT.with(|c| c.borrow().clone()), catch_up: catch_up(), draft: draft(), background: background() } + } + + /// Run `f` with these hints, restoring this thread's however `f` ends (a pool thread must + /// not keep another job's hints after a panic). + pub fn run(self, f: impl FnOnce() -> R) -> R { + struct Restore(Context); + impl Drop for Restore { + fn drop(&mut self) { + let prev = std::mem::take(&mut self.0); + CURRENT.with(|c| *c.borrow_mut() = prev.cancel); + CATCH_UP.with(|c| c.set(prev.catch_up)); + DRAFT.with(|c| c.set(prev.draft)); + BACKGROUND.with(|c| c.set(prev.background)); + } + } + let _restore = Restore(Context::current()); + CURRENT.with(|c| *c.borrow_mut() = self.cancel); + CATCH_UP.with(|c| c.set(self.catch_up)); + DRAFT.with(|c| c.set(self.draft)); + BACKGROUND.with(|c| c.set(self.background)); + f() + } +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn context_carries_the_hints_to_another_thread_and_restores_its_own() { + let flag = Arc::new(AtomicBool::new(true)); + let ctx = with_cancel(&flag, || with_draft(true, || with_background(true, || with_catch_up(Some(Tick(5)), Context::current)))); + std::thread::scope(|s| { + s.spawn(|| { + let seen = with_draft(false, || ctx.clone().run(|| (cancelled(), draft(), background(), catch_up()))); + assert_eq!(seen, (true, true, true, Some(Tick(5)))); + // a panic inside leaves this thread's own hints in place + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| ctx.clone().run(|| panic!("job")))); + assert!(r.is_err()); + assert_eq!((cancelled(), draft(), background(), catch_up()), (false, false, false, None)); + }); + }); + } + #[test] fn flag_is_scoped_to_the_closure() { let flag = Arc::new(AtomicBool::new(false)); From a902b11e80fb2c36d01df18cf5e4e791e3642dc6 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 17:19:58 +0200 Subject: [PATCH 038/144] #653: a dragged trim edge stops at the end of the media Problem: dragging a clip edge in the Timeline previewed the raw pointer delta, so the edge kept growing past the end of the clip's media (or into the neighbouring clip) and only jumped back when the button came up and `timeline.trim` clamped it (#653, point 2 of #374). Fix: `commands::trim_delta` computes, without editing, the delta `timeline.trim` would apply (the same clamp across linked partners, now shared by both), and the Timeline's trim drag shows that delta. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands.rs | 33 +++++++++++++++++++----- crates/engine/src/trim_tests.rs | 18 +++++++++++++ crates/ui-egui/src/panels/timeline.rs | 4 ++- crates/ui-egui/tests/timeline_trim_ui.rs | 32 +++++++++++++++++++++++ 4 files changed, 79 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 0b3a9f8a..8cf771a3 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -366,6 +366,31 @@ pub fn with_links(s: &Session, clips: &[ClipId]) -> Vec { out } +/// The delta a trim of `clips` (a clip and its linked partners) can apply together: `delta` +/// clamped by each one's media and neighbours. +fn clamp_trim_linked(q: &filmcraft_project::Sequence, clips: &[ClipId], edge: Edge, mode: TrimMode, delta: Tick, ctx: &edit::EditCtx) -> Result { + let mut d = delta; + for c in clips { + let x = edit::clamp_trim(q, *c, edge, mode, d, ctx)?; + if x.abs() < d.abs() { + d = x; + } + } + Ok(d) +} + +/// The delta `timeline.trim` would apply for `delta`, without editing anything: the Timeline's +/// trim drag shows this, so the edge stops at the end of the media or the neighbouring clip +/// instead of following the pointer past it (#653). +pub fn trim_delta(s: &Session, clip: ClipId, edge: Edge, mode: TrimMode, delta: Tick) -> Result { + let q = s.active_sequence().ok_or(EngineError::NoSequence)?; + let durations = |id: ItemId| crate::media_duration(&s.project, &s.media, id); + let starts = |id: ItemId| crate::media_start(&s.project, id); + let mut next = s.project.next_id; + let ctx = edit::EditCtx { next_id: &mut next, media_duration: &durations, media_start: &starts, min_duration: q.settings.frame_rate.frame_duration() }; + clamp_trim_linked(q, &with_links(s, &[clip]), edge, mode, delta, &ctx) +} + /// Sequence settings matching a media clip (New Sequence From Clip, New Sequence from an item): /// its frame size, rate and pixel aspect ratio (Interpret Footage's, else the file's), so a /// 1440 x 1080 clip with 4:3 pixels makes a 1440 x 1080 sequence that displays 16:9. @@ -2198,13 +2223,7 @@ fn build() -> Vec { let applied = s.edit_sequence(if mode == TrimMode::Ripple { "Ripple Trim" } else { "Trim" }, |q, ctx, st| { let before = q.find_item(c).map(|(_, i)| i.range()); // clamp across all linked partners, then apply the common delta - let mut dd = d; - for c in &clips { - let x = edit::clamp_trim(q, *c, edge, mode, dd, ctx)?; - if x.abs() < dd.abs() { - dd = x; - } - } + let mut dd = clamp_trim_linked(q, &clips, edge, mode, d, ctx)?; if mode == TrimMode::Ripple { // the clip and its linked partners ripple as one edit let mut group = vec![c]; diff --git a/crates/engine/src/trim_tests.rs b/crates/engine/src/trim_tests.rs index 25735712..b003e48d 100644 --- a/crates/engine/src/trim_tests.rs +++ b/crates/engine/src/trim_tests.rs @@ -236,3 +236,21 @@ fn extend_to_playhead_moves_each_unequal_out_point_to_the_playhead() { assert_eq!(q.find_item(a).unwrap().1.end(), ph); assert_eq!(q.find_item(b).unwrap().1.end(), ph); } + +/// #653: the delta the Timeline previews while an edge is dragged is the one `timeline.trim` +/// applies on release: dragging the last clip's Out edge far past the end of its media stops at +/// the media's end instead of following the pointer. +#[test] +fn trim_delta_stops_at_the_end_of_the_media() { + let mut s = demo(); + let last = *v1(&s).last().unwrap(); + let clip = ClipId(last.0); + let far = Tick(3600 * filmcraft_time::TICKS_PER_SECOND); + let shown = commands::trim_delta(&s, clip, filmcraft_edit::Edge::Out, filmcraft_edit::TrimMode::Regular, far).unwrap(); + assert!(Tick::ZERO < shown && shown < far, "clamped to the media left after the clip: {shown:?}"); + let r = s.execute("timeline.trim", json!({"clip": last.0, "edge": "out", "mode": "regular", "delta": far.0})).unwrap(); + assert_eq!(r["delta"], json!(shown.0), "the preview and the trim agree"); + let small = s.sequence_rate().frame_duration(); + let back = commands::trim_delta(&s, clip, filmcraft_edit::Edge::Out, filmcraft_edit::TrimMode::Regular, -small).unwrap(); + assert_eq!(back, -small, "a trim within the media is not clamped"); +} diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index d5997839..70e25656 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2066,7 +2066,9 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & Drag::Trim { clip, edge, mode, from, .. } => seq.find_item(clip).map(|(_, it)| { let base = if edge == filmcraft_edit::Edge::In { it.start } else { it.end() }; let target = snap(app, seq, layout, rate.snap_nearest(base + (t_here - from)), &[clip]); - Drag::Trim { clip, edge, mode, delta: target - base, from } + // stop at the end of the media (or the neighbour), as the trim on release will (#653) + let delta = filmcraft_engine::commands::trim_delta(&app.session, clip, edge, mode, target - base).unwrap_or(target - base); + Drag::Trim { clip, edge, mode, delta, from } }), Drag::Remix { clip, from, .. } => seq.find_item(clip).map(|(_, it)| { let target = rate.snap_nearest(it.end() + (t_here - from)).max(it.start + rate.frame_duration()); diff --git a/crates/ui-egui/tests/timeline_trim_ui.rs b/crates/ui-egui/tests/timeline_trim_ui.rs index 8ea07cd2..13542a8f 100644 --- a/crates/ui-egui/tests/timeline_trim_ui.rs +++ b/crates/ui-egui/tests/timeline_trim_ui.rs @@ -298,3 +298,35 @@ fn dragging_a_caption_edge_inward_trims_it() { assert_eq!(s1, s0, "the caption's start stayed: trimmed, not moved"); assert!(e1 < e0, "the caption's end moved in: {e0} → {e1}"); } + +/// #653: an Out edge dragged far past the end of the clip's media stops there while the drag is +/// still in progress, at the same place the trim lands on release. It used to follow the pointer +/// past the media and then jump back when the button came up. +#[test] +fn an_edge_dragged_past_the_media_stops_at_its_end() { + use filmcraft_ui_egui::panels::timeline::Drag; + let mut d = Driver::demo(); + let last = d.app().session.active_sequence().expect("sequence").video_tracks[0].items.last().expect("a clip on V1").id.0; + let (_, x1, y) = d.edges(last); + let (_, e0) = d.span(last); + let far = filmcraft_time::Tick(3600 * filmcraft_time::TICKS_PER_SECOND); + let limit = filmcraft_engine::commands::trim_delta(&d.app().session, ClipId(last), filmcraft_edit::Edge::Out, filmcraft_edit::TrimMode::Regular, far) + .expect("limit"); + let (from, to) = (pos2(x1 - 3.0, y), pos2(x1 + 300.0, y)); + let push = |d: &mut Driver, e: egui::Event| d.harness.input_mut().events.push(e); + push(&mut d, egui::Event::PointerMoved(from)); + d.frames(1); + push(&mut d, egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + d.frames(1); + for i in 1..=60 { + push(&mut d, egui::Event::PointerMoved(from + (to - from) * (i as f32 / 60.0))); + d.frames(1); + } + match &d.app().tl.drag { + Some(Drag::Trim { delta, .. }) => assert_eq!(*delta, limit, "the dragged edge stops at the end of the media"), + other => panic!("not trimming: {other:?}"), + } + push(&mut d, egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + d.frames(2); + assert_eq!(d.span(last).1, e0 + limit.0, "released where it was shown"); +} From 3c0fa2ab0b7a031c7f7da4f4f278bb6af692ef51 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 17:21:56 +0200 Subject: [PATCH 039/144] #461: interchange import links media through the streaming reader Problem: after merging an FCP7 XML / OTIO / EDL / AAF document, the import linked each media file by reading it whole into memory (services.read_file + open_bytes). Multi-GB 4K files took minutes and stayed resident, while a direct import opens them in well under a second. Fix: link through MediaPool::open_file, as import_streamed does: the host's random-access reader reads only the container index now and samples on demand (hosts without a reader still read the file whole, as before). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/interchange.rs | 9 ++-- .../src/interchange_auto_points_tests.rs | 42 ++++++++++++++++++- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/interchange.rs b/crates/engine/src/interchange.rs index 84acdf5e..bd37f6bb 100644 --- a/crates/engine/src/interchange.rs +++ b/crates/engine/src/interchange.rs @@ -59,7 +59,8 @@ pub fn import(s: &mut Session, path: &str, bytes: &[u8], format: Format) -> Resu proj.resolve_placed_auto_points(|seq| !before.contains(&seq), |source| !is_file_media(source)); Ok(seqs) })?; - // Link media: probe each new file-backed item that exists. + // Link media: probe each new file-backed item that exists, through the host's reader as a + // direct import does (only the container index is read now, not the whole file: #461). let new_media: Vec<(ItemId, String)> = s .project .items @@ -76,11 +77,7 @@ pub fn import(s: &mut Session, path: &str, bytes: &[u8], format: Format) -> Resu let mut linked = 0; let mut offline = Vec::new(); for (id, mpath) in new_media { - let opened = s.services.read_file(&mpath).ok().and_then(|b| { - let fname = std::path::Path::new(&mpath).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - s.media.open_bytes(&fname, b.into()).ok() - }); - match opened { + match s.media.open_file(&mpath, &*s.services).ok() { Some(src) => { let info = src.info().clone(); let identity = crate::relink::identity_of(&*s.services, &mpath).ok(); diff --git a/crates/engine/src/interchange_auto_points_tests.rs b/crates/engine/src/interchange_auto_points_tests.rs index d5d5e625..b5a94ab0 100644 --- a/crates/engine/src/interchange_auto_points_tests.rs +++ b/crates/engine/src/interchange_auto_points_tests.rs @@ -1,6 +1,6 @@ //! Interchange imports and the "auto" (NaN) point defaults of the effects they add: the points //! are resolved as placing a clip resolves them, the anchor once the media's real size is known, -//! and a project saved after an import opens again. +//! and a project saved after an import opens again. Also how the media they name are linked. use filmcraft_geom::Vec2; use filmcraft_media::DemoScene; @@ -216,3 +216,43 @@ fn relinking_later_resolves_the_anchor_that_waited_for_the_real_size() { assert_eq!(motion_point(&first_clip(&t, seq), "anchor"), Vec2::new(160.0, 90.0)); let _ = std::fs::remove_dir_all(&d); } + +/// Linking the media an interchange document names opens each file through the host's reader, as +/// a direct import does, instead of reading it whole into memory (#461: minutes for 4K files). +#[cfg(any(unix, windows))] +#[test] +fn an_interchange_import_links_media_without_reading_the_files_whole() { + /// The desktop filesystem, except that reading a media file whole fails. + struct NoWholeMediaReads; + impl crate::Services for NoWholeMediaReads { + fn read_file(&self, path: &str) -> std::io::Result> { + if path.ends_with(".mov") { + return Err(std::io::Error::other(format!("{path}: read whole"))); + } + crate::FsServices.read_file(path) + } + fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> { + crate::FsServices.write_file(path, data) + } + fn file_size(&self, path: &str) -> std::io::Result { + crate::FsServices.file_size(path) + } + fn read_range(&self, path: &str, offset: u64, len: usize) -> std::io::Result> { + crate::FsServices.read_range(path, offset, len) + } + fn reader(&self, path: &str) -> Option> { + crate::FsServices.reader(path) + } + } + let d = tmp_dir("ix-streamed-link"); + std::fs::create_dir_all(d.join("media")).unwrap(); + make_movie(&d.join("media/shot.mov"), DemoScene::OceanSunset, 320, 180, 24); + let doc = d.join("cut.xml"); + std::fs::write(&doc, xml_with_a_scale_only_motion_filter()).unwrap(); + let mut s = Session::new(std::sync::Arc::new(NoWholeMediaReads)); + let (r, seq) = import(&mut s, &doc); + assert_eq!(r["linkedMedia"], 1, "{r}"); + let clip = first_clip(&s, seq); + assert_eq!(s.project.source_size(clip.item), Some((320, 180)), "the file's real size, from its index"); + let _ = std::fs::remove_dir_all(&d); +} From 6d1c4fdb842ed53c7fb9a5119ce5f7106657bac2 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 02:28:52 +1100 Subject: [PATCH 040/144] Pass Hard Limiter look-ahead into the DSP limiter. The project Look-Ahead Time parameter was stored but never applied, so 0 ms and 30 ms mixed the same clip. The limiter now sizes its look-ahead from that parameter and pre-rolls for the chosen window. --- crates/audio-dsp/src/effects/dynamics.rs | 44 +++++++++++++++++++++--- crates/render/src/audio_fx.rs | 3 +- crates/render/src/tests.rs | 10 ++++++ 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/crates/audio-dsp/src/effects/dynamics.rs b/crates/audio-dsp/src/effects/dynamics.rs index 5807d1ba..40dec64c 100644 --- a/crates/audio-dsp/src/effects/dynamics.rs +++ b/crates/audio-dsp/src/effects/dynamics.rs @@ -263,6 +263,8 @@ pub struct Limiter { true_peak: bool, /// Look-ahead (samples) and detector alignment delay. look: usize, + /// Delay-line capacity (samples of look-ahead) allocated at construction for 0…30 ms. + max_look: usize, det_delay: usize, bank: PolyphaseBank, interp: Vec, @@ -285,17 +287,21 @@ impl Limiter { ParamSpec::new("input_gain", "Input Boost", 0.0, 24.0, 0.0, Unit::Decibels), ParamSpec::new("ceiling", "Ceiling", -24.0, 0.0, -1.0, Unit::Decibels), ParamSpec::log("release", "Release", 1.0, 1000.0, 60.0, Unit::Milliseconds), + ParamSpec::new("lookahead", "Look-Ahead", 0.0, 30.0, 5.0, Unit::Milliseconds), ParamSpec::toggle("true_peak", "True Peak", true), ]; - /// Fixed look-ahead time. + /// Default look-ahead when the parameter is left at its DSP default. pub const LOOKAHEAD_MS: f32 = 5.0; + /// Longest look-ahead the delay line is sized for (Hard Limiter's 0…30 ms range). + pub const MAX_LOOKAHEAD_MS: f32 = 30.0; pub fn new(sample_rate: f32, channels: usize) -> Self { let channels = channels.max(1); let bank = PolyphaseBank::new(true_peak_factor(sample_rate as f64).max(2)); - let look = ((Self::LOOKAHEAD_MS * 0.001 * sample_rate).round() as usize).max(1); + let max_look = ((Self::MAX_LOOKAHEAD_MS * 0.001 * sample_rate).round() as usize).max(1); + let look = ((Self::LOOKAHEAD_MS * 0.001 * sample_rate).round() as usize).max(1).min(max_look); let det_delay = bank.delay(); - let dlen = look + det_delay + 1; + let dlen = max_look + det_delay + 1; let mut s = Limiter { pv: ParamValues::new(Self::PARAMS), sr: sample_rate, @@ -304,6 +310,7 @@ impl Limiter { rel: 0.0, true_peak: true, look, + max_look, det_delay, bank, interp: vec![Interpolator::default(); channels], @@ -326,6 +333,15 @@ impl Limiter { self.ceiling = db_to_gain(self.pv.v("ceiling")); self.rel = coef(self.pv.v("release"), self.sr); self.true_peak = self.pv.on("true_peak"); + let look = ((self.pv.v("lookahead") * 0.001 * self.sr).round() as usize).max(1).min(self.max_look); + if look != self.look { + self.look = look; + self.minq.clear(); + self.box_ring = vec![1.0; look + 1]; + self.box_pos = 0; + self.box_sum = (look + 1) as f64; + self.rel_state = 1.0; + } if snap { self.in_gain.snap(); } @@ -363,7 +379,7 @@ impl AudioEffect for Limiter { for i in 0..n { let g_in = self.in_gain.tick(); let det_pos = (self.wpos + dlen - self.det_delay) % dlen; - let out_pos = (self.wpos + 1) % dlen; // oldest = look + det_delay samples ago + let out_pos = (self.wpos + dlen - (self.look + self.det_delay)) % dlen; let mut det = 0.0f32; for ch in 0..nch { let x = channels[ch][i] * g_in; @@ -555,4 +571,24 @@ mod tests { let peak = tail.iter().fold(0.0f32, |a, v| a.max(v.abs())); assert!((lin_to_db(peak) + 6.0).abs() < 0.3, "{}", lin_to_db(peak)); } + + #[test] + fn limiter_lookahead_changes_latency_and_transient_shape() { + let mut a = Limiter::new(SR, 1); + let mut b = Limiter::new(SR, 1); + a.set_param("lookahead", 0.0); + b.set_param("lookahead", 30.0); + a.set_param("ceiling", -6.0); + b.set_param("ceiling", -6.0); + assert!(b.latency() > a.latency(), "{} vs {}", b.latency(), a.latency()); + let mut xa: Vec = (0..4800).map(|i| 0.08 * (2.0 * std::f32::consts::PI * 997.0 * i as f32 / SR).sin()).collect(); + for s in &mut xa[1200..1248] { + *s = 0.9; + } + let mut xb = xa.clone(); + a.process(&mut [&mut xa]); + b.process(&mut [&mut xb]); + let max = xa.iter().zip(&xb).map(|(x, y)| (x - y).abs()).fold(0.0f32, f32::max); + assert!(max > 1e-4, "look-ahead of 0 ms and 30 ms produced the same waveform (max Δ {max})"); + } } diff --git a/crates/render/src/audio_fx.rs b/crates/render/src/audio_fx.rs index db84a1f6..adf9d35e 100644 --- a/crates/render/src/audio_fx.rs +++ b/crates/render/src/audio_fx.rs @@ -44,11 +44,12 @@ pub(crate) fn mapping(id: &str) -> Option { }, "hard_limiter" => Mapping { dsp: "limiter", - preroll: |e, t| (f(e, "release", t) as f64 / 1000.0 * 5.0).clamp(0.05, 2.0), + preroll: |e, t| (f(e, "lookahead", t) as f64 / 1000.0 + f(e, "release", t) as f64 / 1000.0 * 5.0).clamp(0.05, 2.0), apply: |d, e, t| { d.set_param("ceiling", f(e, "max", t)); d.set_param("input_gain", f(e, "boost", t).max(0.0)); d.set_param("release", f(e, "release", t)); + d.set_param("lookahead", f(e, "lookahead", t)); }, }, "delay" => Mapping { diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 31f5edfc..ecb02d9d 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -301,6 +301,16 @@ fn audio_fx_limiter_holds_ceiling() { assert!(peak <= ceiling * 1.01 && peak > ceiling * 0.7, "peak {peak} vs ceiling {ceiling}"); } +#[test] +fn audio_fx_limiter_lookahead_changes_the_mix() { + let (p0, seq0, map0) = tone_with(&[("amplify", &[("gain", 30.0)]), ("hard_limiter", &[("max", -6.0), ("lookahead", 0.0)])]); + let (p1, seq1, map1) = tone_with(&[("amplify", &[("gain", 30.0)]), ("hard_limiter", &[("max", -6.0), ("lookahead", 30.0)])]); + let a = mix(&p0, seq0, &map0, 0, 48_000); + let b = mix(&p1, seq1, &map1, 0, 48_000); + let max = a.iter().zip(&b).map(|(x, y)| (x - y).abs()).fold(0f32, f32::max); + assert!(max > 1e-4, "look-ahead of 0 ms and 30 ms mixed the same clip"); +} + #[test] fn audio_fx_chain_is_continuous_and_random_access_matches() { let (p, seq, map) = tone_with(&[("delay", &[("delay", 0.05), ("feedback", 50.0), ("mix", 50.0)])]); From 6a995d7c0a07cc671c32c0bbcf564e91f4456698 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 17:30:17 +0200 Subject: [PATCH 041/144] #333: Fade In and Fade Out presets add up on one clip Problem: presets.apply replaced an intrinsic effect (Opacity) wholesale, so applying Fade Out after Fade In discarded the Fade In keyframes (and vice versa). Fix: when a preset lands on an intrinsic effect, keep the clip's existing keyframes that lie outside the span the preset animates for that parameter. Re-applying the same preset still replaces its own ramp; static preset values behave as before. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/presets.rs | 18 +++++++++++++++--- crates/engine/src/presets_tests.rs | 19 +++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/presets.rs b/crates/engine/src/presets.rs index 75f71144..bd1c14e8 100644 --- a/crates/engine/src/presets.rs +++ b/crates/engine/src/presets.rs @@ -365,12 +365,24 @@ pub fn instantiate(p: &EffectPreset, target: &TrackItem, size: (u32, u32)) -> Ve out } -/// Put preset effects on a clip: intrinsic ones replace the clip's instance, others are inserted -/// before the intrinsic effects. +/// Keep `old`'s keyframes that lie outside the span `new` animates, so presets keyframing the same +/// parameter at different times (Fade In, then Fade Out) add up instead of replacing each other. +fn merge_keyframes(new: &mut EffectInstance, old: &EffectInstance) { + for (id, p) in new.params.iter_mut() { + let (Some(first), Some(last)) = (p.keyframes.first().map(|k| k.time), p.keyframes.last().map(|k| k.time)) else { continue }; + let Some(o) = old.params.get(id) else { continue }; + p.keyframes.extend(o.keyframes.iter().filter(|k| k.time < first || k.time > last).cloned()); + p.keyframes.sort_by_key(|k| k.time); + } +} + +/// Put preset effects on a clip: intrinsic ones replace the clip's instance (keeping its keyframes +/// outside the span the preset animates), others are inserted before the intrinsic effects. pub fn put_effects(it: &mut TrackItem, effects: Vec) { - for e in effects { + for mut e in effects { let intrinsic = e.def().is_some_and(|d| d.intrinsic); if intrinsic && let Some(slot) = it.effects.iter_mut().find(|x| x.effect == e.effect) { + merge_keyframes(&mut e, slot); *slot = e; continue; } diff --git a/crates/engine/src/presets_tests.rs b/crates/engine/src/presets_tests.rs index db8065b3..7a2ab0e1 100644 --- a/crates/engine/src/presets_tests.rs +++ b/crates/engine/src/presets_tests.rs @@ -194,6 +194,25 @@ fn builtins_apply_and_user_presets_persist() { let _ = std::fs::remove_dir_all(&dir); } +/// #333: Fade In then Fade Out on one clip keeps both ramps (they keyframe the same Opacity). +#[test] +fn fade_in_and_fade_out_add_up_on_one_clip() { + let (mut s, clips) = demo(); + let c = clips[0]; + s.execute("presets.apply", json!({"preset": "Fade In", "clips": [c.0]})).unwrap(); + s.execute("presets.apply", json!({"preset": "Fade Out", "clips": [c.0]})).unwrap(); + let it = item(&s, c); + let op = it.effect("opacity").unwrap().param("opacity").unwrap(); + let keys: Vec<(Tick, ParamValue)> = op.keyframes.iter().map(|k| (k.time, k.value.clone())).collect(); + let (tin, tout) = (it.source_in, it.source_in + media_len(&it)); + let f = ParamValue::Float; + assert_eq!(keys, vec![(tin, f(0.0)), (tin + secs(1.0), f(100.0)), (tout - secs(1.0), f(100.0)), (tout, f(0.0))], "both fades survive"); + // re-applying Fade In replaces its own ramp, not the Fade Out one + s.execute("presets.apply", json!({"preset": "Fade In", "clips": [c.0]})).unwrap(); + let it = item(&s, c); + assert_eq!(it.effect("opacity").unwrap().param("opacity").unwrap().keyframes.len(), 4); +} + #[test] fn mask_geometry_scales_to_the_target_frame_size() { let (s, clips) = demo(); From 80db16ebd5184dd357c66467d8680322159d42e6 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 02:34:08 +1100 Subject: [PATCH 042/144] Apply Ultra Key Setting as a parameter preset. Relaxed and Aggressive previously only scaled two internal multipliers, so the matte stayed at Default. Switching Setting now writes Premiere-like matte values and the renderer uses that set, including Custom for hand-tuned keys. --- crates/engine/src/commands.rs | 34 +++++++++++++++----- crates/engine/src/vfx_tests.rs | 13 ++++++++ crates/project/src/effect.rs | 2 +- crates/project/src/effect/vfx.rs | 54 ++++++++++++++++++++++++++++++++ crates/render/src/vfx/color.rs | 35 ++++++++++----------- crates/render/src/vfx/tests.rs | 6 ++++ 6 files changed, 117 insertions(+), 27 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 0b3a9f8a..dd169aed 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -2571,15 +2571,33 @@ fn build() -> Vec { { e.params.insert(pid.clone(), filmcraft_project::Param::new(d.default.clone())); } - let prm = crate::masks::target_param(e, &pq, &pid).ok_or_else(|| bad("effects.setParam", format!("no param `{pid}`")))?; - let v = json_to_param(&prm.value, &val).ok_or_else(|| bad("effects.setParam", "value has the wrong type"))?; - if keyframe { - // the stopwatch and the value in one step - prm.put_keyframe(mt, v); - } else { - prm.set_at(mt, v); + let (keyframes, v) = { + let prm = crate::masks::target_param(e, &pq, &pid).ok_or_else(|| bad("effects.setParam", format!("no param `{pid}`")))?; + let v = json_to_param(&prm.value, &val).ok_or_else(|| bad("effects.setParam", "value has the wrong type"))?; + let keyframes = if keyframe { + // the stopwatch and the value in one step + prm.put_keyframe(mt, v.clone()); + prm.keyframes.len() + } else { + prm.set_at(mt, v.clone()); + prm.keyframes.len() + }; + (keyframes, v) + }; + if e.effect == "ultra_key" && pid == "setting" + && let ParamValue::Choice(setting) = v + && let Some(rows) = filmcraft_project::effect::ultra_key_setting(setting) + { + for (id, value) in rows { + if let Some(p) = e.param_mut(id) { + p.value = ParamValue::Float(*value); + p.keyframes.clear(); + } else { + e.params.insert((*id).to_string(), filmcraft_project::Param::new(ParamValue::Float(*value))); + } + } } - Ok(prm.keyframes.len()) + Ok(keyframes) })?; // 0 keyframes: the value is static (`time` was not used) Ok(json!({"keyframes": keyframes})) diff --git a/crates/engine/src/vfx_tests.rs b/crates/engine/src/vfx_tests.rs index cb2df958..f1aeb598 100644 --- a/crates/engine/src/vfx_tests.rs +++ b/crates/engine/src/vfx_tests.rs @@ -115,3 +115,16 @@ fn new_effect_params_are_settable_and_keyframable() { let img = s.render_program(0.125).unwrap(); assert!(img.px.iter().all(|v| v.is_finite())); } + +#[test] +fn ultra_key_setting_writes_aggressive_parameters() { + let mut s = demo(); + let clip = first_v1_clip(&s); + s.execute("effects.apply", json!({"clips": [clip], "effect": "ultra_key"})).unwrap(); + s.execute("effects.setParam", json!({"clip": clip, "effect": "ultra_key", "param": "setting", "value": 2})).unwrap(); + let e = s.active_sequence().unwrap().find_item(filmcraft_project::ClipId(clip)).unwrap().1.effect("ultra_key").unwrap(); + assert_eq!(e.param("tolerance").unwrap().value.as_f64(), Some(90.0)); + assert_eq!(e.param("pedestal").unwrap().value.as_f64(), Some(50.0)); + assert_eq!(e.param("choke").unwrap().value.as_f64(), Some(10.0)); + assert_eq!(e.param("contrast").unwrap().value.as_f64(), Some(10.0)); +} diff --git a/crates/project/src/effect.rs b/crates/project/src/effect.rs index 1abd4e66..d83c31aa 100644 --- a/crates/project/src/effect.rs +++ b/crates/project/src/effect.rs @@ -17,7 +17,7 @@ use crate::mask::Mask; pub use audio::{GEQ10_LABELS, GEQ20_LABELS, GEQ30_LABELS, PREMIERE_AUDIO_EFFECTS}; mod vfx; -pub use vfx::{EASINGS, ECHO_OPERATORS, FRAME_LAYOUTS, LIGHT_IDS, SIMPLE_BLEND, TRACK_CHOICES, auto_point}; +pub use vfx::{EASINGS, ECHO_OPERATORS, FRAME_LAYOUTS, LIGHT_IDS, SIMPLE_BLEND, TRACK_CHOICES, auto_point, ultra_key_setting}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum EffectKind { diff --git a/crates/project/src/effect/vfx.rs b/crates/project/src/effect/vfx.rs index 7833c51b..70d9dae1 100644 --- a/crates/project/src/effect/vfx.rs +++ b/crates/project/src/effect/vfx.rs @@ -1014,6 +1014,60 @@ pub(super) fn defs() -> Vec { v } +/// Parameter values written when Ultra Key's Setting is Default, Relaxed or Aggressive. +/// Custom (index 3) leaves the current values. Aggressive matches the matte that cleans +/// light and dark clothing on a typical green screen. +pub fn ultra_key_setting(setting: u32) -> Option<&'static [(&'static str, f64)]> { + Some(match setting { + 0 => &[ + ("transparency", 45.0), + ("highlight", 10.0), + ("shadow", 50.0), + ("tolerance", 50.0), + ("pedestal", 10.0), + ("choke", 0.0), + ("soften", 0.0), + ("contrast", 0.0), + ("mid_point", 50.0), + ("desaturate", 25.0), + ("range", 50.0), + ("spill", 50.0), + ("spill_luma", 50.0), + ], + 1 => &[ + ("transparency", 30.0), + ("highlight", 15.0), + ("shadow", 35.0), + ("tolerance", 70.0), + ("pedestal", 5.0), + ("choke", 0.0), + ("soften", 8.0), + ("contrast", 0.0), + ("mid_point", 50.0), + ("desaturate", 15.0), + ("range", 50.0), + ("spill", 40.0), + ("spill_luma", 50.0), + ], + 2 => &[ + ("transparency", 40.0), + ("highlight", 10.0), + ("shadow", 55.0), + ("tolerance", 90.0), + ("pedestal", 50.0), + ("choke", 10.0), + ("soften", 10.0), + ("contrast", 10.0), + ("mid_point", 50.0), + ("desaturate", 50.0), + ("range", 50.0), + ("spill", 50.0), + ("spill_luma", 50.0), + ], + _ => return None, + }) +} + /// Extra parameters added to core effects by the 26.x rebuilds (old instances fall back to the /// defaults, so projects round-trip unchanged). pub(super) fn extend_core(v: &mut [EffectDef]) { diff --git a/crates/render/src/vfx/color.rs b/crates/render/src/vfx/color.rs index 35f4e464..29c8f60a 100644 --- a/crates/render/src/vfx/color.rs +++ b/crates/render/src/vfx/color.rs @@ -161,17 +161,16 @@ pub fn ultra_key(img: &mut Image, e: &EffectInstance, cx: &FxCtx) { let k = rgb_to_ycbcr(kc[0], kc[1], kc[2], Matrix::Bt709); let kmag2 = (k[1] * k[1] + k[2] * k[2]).max(1e-6); let setting = chv(e, "setting"); - let (g_mul, ped_mul) = match setting { - 1 => (0.85, 0.7), - 2 => (1.2, 1.5), - _ => (1.0, 1.0), + let preset = filmcraft_project::effect::ultra_key_setting(setting); + let pf = |id: &str, fallback: f32| { + preset.and_then(|rows| rows.iter().find(|(k, _)| *k == id).map(|(_, v)| *v as f32)).unwrap_or(fallback) }; - let transparency = fv(e, "transparency", cx) / 100.0; - let highlight = fv(e, "highlight", cx) / 100.0; - let shadow = fv(e, "shadow", cx) / 100.0; - let tol = 0.08 + fv(e, "tolerance", cx) / 100.0 * 0.5; - let pedestal = (fv(e, "pedestal", cx) / 100.0 * 0.3 * ped_mul).min(0.9); - let gain = (1.0 + 2.0 * transparency) * g_mul; + let transparency = pf("transparency", fv(e, "transparency", cx)) / 100.0; + let highlight = pf("highlight", fv(e, "highlight", cx)) / 100.0; + let shadow = pf("shadow", fv(e, "shadow", cx)) / 100.0; + let tol = 0.08 + pf("tolerance", fv(e, "tolerance", cx)) / 100.0 * 0.5; + let pedestal = (pf("pedestal", fv(e, "pedestal", cx)) / 100.0 * 0.3).min(0.9); + let gain = 1.0 + 2.0 * transparency; let output = chv(e, "output"); let dom = if kc[1] >= kc[0] && kc[1] >= kc[2] { 1 @@ -199,10 +198,10 @@ pub fn ultra_key(img: &mut Image, e: &EffectInstance, cx: &FxCtx) { *a = ((m.clamp(0.0, 1.0) - pedestal) / (1.0 - pedestal)).clamp(0.0, 1.0); }); // cleanup - let choke = fv(e, "choke", cx) / 100.0; - let soften = fv(e, "soften", cx) / 100.0; - let contrast = fv(e, "contrast", cx) / 100.0; - let mid = fv(e, "mid_point", cx) / 100.0; + let choke = pf("choke", fv(e, "choke", cx)) / 100.0; + let soften = pf("soften", fv(e, "soften", cx)) / 100.0; + let contrast = pf("contrast", fv(e, "contrast", cx)) / 100.0; + let mid = pf("mid_point", fv(e, "mid_point", cx)) / 100.0; if choke > 0.0 { let r = (choke * 4.0 * cx.px_scale).max(0.5); let b = blur_plane(&alpha, img.w, img.h, r); @@ -217,10 +216,10 @@ pub fn ultra_key(img: &mut Image, e: &EffectInstance, cx: &FxCtx) { alpha.par_iter_mut().for_each(|a| *a = ((*a - mid) * g + mid).clamp(0.0, 1.0)); } // spill suppression + colour correction on the foreground - let spill = fv(e, "spill", cx) / 100.0; - let desat = fv(e, "desaturate", cx) / 100.0; - let range = fv(e, "range", cx) / 100.0; - let sluma = fv(e, "spill_luma", cx) / 100.0; + let spill = pf("spill", fv(e, "spill", cx)) / 100.0; + let desat = pf("desaturate", fv(e, "desaturate", cx)) / 100.0; + let range = pf("range", fv(e, "range", cx)) / 100.0; + let sluma = pf("spill_luma", fv(e, "spill_luma", cx)) / 100.0; let (cc_s, cc_h, cc_l) = (fv(e, "cc_saturation", cx) / 100.0, fv(e, "cc_hue", cx) / 360.0, fv(e, "cc_luminance", cx) / 100.0); let cc = (cc_s - 1.0).abs() > 1e-4 || cc_h.abs() > 1e-6 || (cc_l - 1.0).abs() > 1e-4; img.px.par_chunks_mut(4).zip(alpha.par_iter()).for_each(|(p, &a)| { diff --git a/crates/render/src/vfx/tests.rs b/crates/render/src/vfx/tests.rs index 6a00196d..9d462e28 100644 --- a/crates/render/src/vfx/tests.rs +++ b/crates/render/src/vfx/tests.rs @@ -504,6 +504,12 @@ fn keyers_known_values() { set(&mut e, "output", ParamValue::Choice(1)); let m = run("ultra_key", &e, &img, &cx()); assert!(m.get(1, 0)[0] < 0.05 && m.get(6, 0)[0] > 0.9 && m.get(1, 0)[3] == 1.0); + let mut aggressive = inst("ultra_key"); + set(&mut aggressive, "setting", ParamValue::Choice(2)); + let ag = run("ultra_key", &aggressive, &img, &cx()); + let def = run("ultra_key", &inst("ultra_key"), &img, &cx()); + let delta = (0..img.w * img.h).map(|i| (ag.px[i * 4 + 3] - def.px[i * 4 + 3]).abs()).fold(0.0f32, f32::max); + assert!(delta > 1e-4, "Aggressive Setting left the Default matte unchanged"); // Alpha Adjust: invert and opacity let mut a = Image::filled(2, 2, [0.1, 0.1, 0.1, 0.25]); let mut e = inst("alpha_adjust"); From 0a1f9730c8553e5fceae7103256e3f33b42fecdb Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 02:36:46 +1100 Subject: [PATCH 043/144] Add file.import replace mode for empty-project round-trips. Headless import merged into the current project, so a demo or leftover timeline mixed into export. replace:true starts from file.newProject with the same name, then imports, which is the documented empty-project-start workflow. --- crates/engine/src/commands.rs | 6 +++++- crates/engine/src/image_sequence_tests.rs | 18 ++++++++++++++++++ docs/architecture.md | 4 +++- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 0b3a9f8a..20a21a64 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -913,7 +913,7 @@ fn build() -> Vec { Ok(json!({"items": ids.iter().map(|i| i.0).collect::>()})) } ), - cmd!("file.import", "Import…", ["File"], Some("Cmd+I"), r#"{"paths":[str],"bin":binId?,"imageSequence":bool?}"#, always, |s, p| { + cmd!("file.import", "Import…", ["File"], Some("Cmd+I"), r#"{"paths":[str],"bin":binId?,"imageSequence":bool?,"replace":bool?}"#, always, |s, p| { let bin = u64_p(p, "bin").map(filmcraft_project::BinId); let paths: Vec = match p.get("paths").and_then(Value::as_array) { Some(a) => a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect(), @@ -922,6 +922,10 @@ fn build() -> Vec { if paths.is_empty() { return Err(bad("file.import", "need `paths`")); } + if bool_p(p, "replace").unwrap_or(false) { + let name = s.project.name.clone(); + s.execute("file.newProject", json!({ "name": name }))?; + } let mut ids = Vec::new(); let mut errors = Vec::new(); let mut sequences = Vec::new(); diff --git a/crates/engine/src/image_sequence_tests.rs b/crates/engine/src/image_sequence_tests.rs index ca36d9cc..8d028fe7 100644 --- a/crates/engine/src/image_sequence_tests.rs +++ b/crates/engine/src/image_sequence_tests.rs @@ -11,6 +11,24 @@ use serde_json::{Value, json}; use crate::Session; use crate::media_test_util::tmp_dir; +#[test] +fn import_replace_starts_from_an_empty_project() { + let dir = tmp_dir("import-replace"); + let wav = dir.join("tone.wav"); + std::fs::write(&wav, filmcraft_media::wav::write_wav16(&[0.1; 4800], 2, 48_000)).unwrap(); + let mut s = demo(); + let demo_items = s.project.items.len(); + assert!(demo_items > 1); + assert!(!s.state.open_sequences.is_empty()); + s.execute("file.import", json!({"paths": [wav.to_string_lossy()], "replace": true})).unwrap(); + assert!(s.state.open_sequences.is_empty()); + assert_eq!(s.project.items.len(), 1); + assert!(s.project.items.values().any(|it| it.name == "tone.wav")); + let mut merge = demo(); + merge.execute("file.import", json!({"paths": [wav.to_string_lossy()]})).unwrap(); + assert!(merge.project.items.len() > demo_items); +} + fn demo() -> Session { let mut s = Session::default(); s.execute("file.openDemoProject", json!({})).unwrap(); diff --git a/docs/architecture.md b/docs/architecture.md index 9481641f..cdcc5ef3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -694,7 +694,9 @@ Video encoders implement `export::VideoEncoder`. Codec crates plug in with `regi Timelines can be exchanged as EDL, FCP7 XML, FCPXML, OTIO, AAF or OMF. `file.import` detects these formats and merges the result into the project as one undoable step (AAF / OMF embedded audio is -written next to the document first), and `file.exportInterchange`, `file.exportEdl`, +written next to the document first). `file.import {replace: true}` starts from an empty project +with the same name (`file.newProject`) so a headless import→export round-trip contains only the +imported timeline. `file.exportInterchange`, `file.exportEdl`, `file.exportFcpxml`, `file.exportOtio`, `file.exportAaf` and `file.exportOmf` write them. `file.exportInterchange {format}` takes `edl`, `xml` (the default), `fcpxml`, `otio`, `aaf` or `omf`; any other value is a parameter error and nothing is written. For AAF and From ec7c43eea21318a488e0cd38c3d09befac2d5e75 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 02:39:53 +1100 Subject: [PATCH 044/144] Treat media extensions as case-insensitive on import. Camera files such as CLIP.MOV were hidden or skipped on Linux because sibling matching and tests assumed lowercase suffixes. Import, the media browser, and image-sequence numbering now accept mixed-case extensions. --- crates/engine/src/image_sequence_tests.rs | 11 +++++++++++ crates/engine/src/media_browser_tests.rs | 5 +++-- crates/media/src/lib.rs | 14 ++++++++++++++ crates/media/src/sequence.rs | 11 ++++++++++- 4 files changed, 38 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/image_sequence_tests.rs b/crates/engine/src/image_sequence_tests.rs index ca36d9cc..c8f213a2 100644 --- a/crates/engine/src/image_sequence_tests.rs +++ b/crates/engine/src/image_sequence_tests.rs @@ -11,6 +11,17 @@ use serde_json::{Value, json}; use crate::Session; use crate::media_test_util::tmp_dir; +#[test] +fn import_accepts_uppercase_extensions() { + let dir = tmp_dir("import-uppercase"); + let wav = dir.join("TONE.WAV"); + std::fs::write(&wav, filmcraft_media::wav::write_wav16(&[0.1; 4800], 2, 48_000)).unwrap(); + let mut s = Session::default(); + let r = s.execute("file.import", json!({"paths": [wav.to_string_lossy()]})).unwrap(); + assert_eq!(r["items"].as_array().unwrap().len(), 1); + assert!(s.project.items.values().any(|it| it.name == "TONE.WAV")); +} + fn demo() -> Session { let mut s = Session::default(); s.execute("file.openDemoProject", json!({})).unwrap(); diff --git a/crates/engine/src/media_browser_tests.rs b/crates/engine/src/media_browser_tests.rs index 972220b3..7b26dc0c 100644 --- a/crates/engine/src/media_browser_tests.rs +++ b/crates/engine/src/media_browser_tests.rs @@ -29,6 +29,7 @@ impl FakeFs { "/home/me/Movies/edit.edl", "/home/me/Movies/subs.srt", "/home/me/Movies/clip.mov", + "/home/me/Movies/CLIP.MOV", ] { files.insert(f.to_string(), vec![0; 16]); } @@ -100,7 +101,7 @@ fn listing_puts_folders_first_and_hides_unsupported_files() { assert_eq!(l["dir"], "/home/me"); assert_eq!(names(&l), ["Movies"]); let l = s.execute("mediaBrowser.list", json!({"path": "/home/me/Movies"})).unwrap(); - assert_eq!(names(&l), ["Selects", "A-interview.wav", "b-roll.wav", "clip.mov", "edit.edl", "shot_0001.png", "shot_0002.png", "subs.srt"]); + assert_eq!(names(&l), ["Selects", "A-interview.wav", "b-roll.wav", "CLIP.MOV", "clip.mov", "edit.edl", "shot_0001.png", "shot_0002.png", "subs.srt"]); let e = &l["entries"][0]; assert_eq!((e["isDir"].as_bool(), e["kind"].as_str()), (Some(true), Some("folder"))); let shot = l["entries"].as_array().unwrap().iter().find(|e| e["name"] == "shot_0001.png").unwrap(); @@ -117,7 +118,7 @@ fn file_type_filter() { let l = s.execute("mediaBrowser.list", json!({"path": "/home/me/Movies", "fileTypes": "image"})).unwrap(); assert_eq!(names(&l), ["Selects", "shot_0001.png", "shot_0002.png"]); let l = s.execute("mediaBrowser.list", json!({"path": "/home/me/Movies", "fileTypes": "mov"})).unwrap(); - assert_eq!(names(&l), ["Selects", "clip.mov"]); + assert_eq!(names(&l), ["Selects", "CLIP.MOV", "clip.mov"]); // the setting persists and applies by default s.execute("mediaBrowser.settings", json!({"fileTypes": "project"})).unwrap(); let l = s.execute("mediaBrowser.list", json!({"path": "/home/me/Movies"})).unwrap(); diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index 6b5ff1b1..d65a0630 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -180,6 +180,20 @@ pub fn is_importable(path: &Path) -> bool { STILL_EXTENSIONS.contains(&ext.as_str()) || AUDIO_EXTENSIONS.contains(&ext.as_str()) || VIDEO_EXTENSIONS.contains(&ext.as_str()) } +#[cfg(test)] +mod importable_tests { + use super::*; + use std::path::Path; + + #[test] + fn camera_extensions_match_regardless_of_case() { + for name in ["clip.mov", "clip.MOV", "clip.Mov", "shot.MP4", "IMG_0001.JPG", "sound.WAV", "take.M2TS"] { + assert!(is_importable(Path::new(name)), "{name}"); + } + assert!(!is_importable(Path::new("notes.txt"))); + } +} + /// A pluggable opener (containers/codecs register themselves here from higher-level crates). pub type Opener = fn(name: &str, bytes: Arc<[u8]>) -> Option>; diff --git a/crates/media/src/sequence.rs b/crates/media/src/sequence.rs index 9521cba4..8c60e015 100644 --- a/crates/media/src/sequence.rs +++ b/crates/media/src/sequence.rs @@ -75,7 +75,15 @@ impl Numbered { /// The frame number of a sibling file name of this sequence (same prefix, suffix and padding). pub fn number_of(&self, file_name: &str) -> Option { - let mid = file_name.strip_prefix(self.prefix.as_str())?.strip_suffix(self.suffix.as_str())?; + let rest = file_name.strip_prefix(self.prefix.as_str())?; + let mid = { + let n = rest.as_bytes(); + let s = self.suffix.as_bytes(); + if n.len() < s.len() || !n[n.len() - s.len()..].eq_ignore_ascii_case(s) { + return None; + } + &rest[..n.len() - s.len()] + }; if mid.is_empty() || !mid.bytes().all(|b| b.is_ascii_digit()) || mid.len() > 18 { return None; } @@ -231,6 +239,7 @@ mod tests { assert!(!u.padded()); assert_eq!(u.file_name(10), "frame10.TIF"); assert_eq!(u.number_of("frame10.TIF"), Some(10)); + assert_eq!(u.number_of("frame10.tif"), Some(10), "Linux stills often mix .TIF and .tif"); assert_eq!(Numbered::parse("clip.mov"), None, "not a still"); assert_eq!(Numbered::parse("nodigits.png"), None); let w = Numbered::parse("C:\\renders\\out.0010.exr.png").unwrap(); From 10ffc016b8977af11101679ea21e4b68401f7e5b Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 17:44:06 +0200 Subject: [PATCH 045/144] #284: reset buttons keep keyframes; a reset button on every property Problem: Reset Effect replaced the effect with a fresh instance, so every keyframe (and every mask) on it was deleted, and Effect Controls had no way to reset a single property. Fix: effects.reset now resets the parameters in place, as Premiere does: a static parameter gets its default, an animated one keeps its keyframes and gets the default as a keyframe at the playhead; masks stay. New effects.resetParam does the same for one parameter, and every Effect Controls property row has a reset button for it (automation id effectControls...reset). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/clip_ops_tests.rs | 34 ++++++++++++ crates/engine/src/commands.rs | 58 +++++++++++++++++--- crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/zh-cn.tsv | 1 + crates/ui-egui/src/panels/effect_controls.rs | 11 ++++ crates/ui-egui/tests/keyframes_ui.rs | 16 ++++++ 7 files changed, 115 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/clip_ops_tests.rs b/crates/engine/src/clip_ops_tests.rs index b6fcfe86..8e90933a 100644 --- a/crates/engine/src/clip_ops_tests.rs +++ b/crates/engine/src/clip_ops_tests.rs @@ -923,3 +923,37 @@ fn linked_slip_keeps_picture_and_sound_aligned_at_unequal_media_limits() { s.execute("timeline.slip", json!({"clip": v, "deltaFrames": -10})).unwrap(); assert_eq!((clip(&s, v).source_in, clip(&s, a).source_in), (Tick(fr.0 * 10), Tick(fr.0 * 10))); } + +#[test] +fn reset_keeps_keyframes_and_resets_one_parameter() { + // #284: Reset Effect used to replace the effect with a fresh one, deleting every keyframe + let mut s = demo(); + let a = v1(&s)[0].clone(); + let sec = Tick(TICKS_PER_SECOND); + let (t0, t1, mid) = (a.start, a.start + sec, a.start + Tick(TICKS_PER_SECOND / 2)); + for (t, v) in [(t0, 150.0), (t1, 50.0)] { + s.execute("effects.setParam", json!({"clip": a.id.0, "effect": "motion", "param": "scale", "value": v, "keyframe": true, "time": t.0})).unwrap(); + } + s.execute("effects.setParam", json!({"clip": a.id.0, "effect": "motion", "param": "rotation", "value": 30.0})).unwrap(); + let idx = clip(&s, a.id.0).effects.iter().position(|e| e.effect == "motion").unwrap(); + s.execute("playhead.set", json!({"time": mid.0})).unwrap(); + s.execute("effects.reset", json!({"clip": a.id.0, "index": idx})).unwrap(); + let it = clip(&s, a.id.0); + let scale = it.effect("motion").unwrap().param("scale").unwrap(); + // (the playhead snaps to a frame) + let mid_media = it.source_time_at(s.playhead()); + let kf: Vec<(Tick, f64)> = scale.keyframes.iter().map(|k| (k.time, k.value.as_f64().unwrap())).collect(); + assert_eq!(kf, vec![(it.source_time_at(t0), 150.0), (mid_media, 100.0), (it.source_time_at(t1), 50.0)], "keyframes kept, default at the playhead"); + assert_eq!(f64_param(&it, "motion", "rotation"), 0.0); + // one parameter: only it goes back to its default + s.execute("effects.setParam", json!({"clip": a.id.0, "effect": "motion", "param": "rotation", "value": 45.0})).unwrap(); + s.execute("effects.setParam", json!({"clip": a.id.0, "effect": "opacity", "param": "opacity", "value": 40.0})).unwrap(); + s.execute("effects.resetParam", json!({"clip": a.id.0, "effect": "motion", "param": "rotation"})).unwrap(); + let it = clip(&s, a.id.0); + assert_eq!(f64_param(&it, "motion", "rotation"), 0.0); + assert_eq!(f64_param(&it, "opacity", "opacity"), 40.0); + assert_eq!(it.effect("motion").unwrap().param("scale").unwrap().keyframes.len(), 3); + assert!(s.execute("effects.resetParam", json!({"clip": a.id.0, "effect": "motion", "param": "nope"})).is_err()); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(f64_param(&clip(&s, a.id.0), "motion", "rotation"), 45.0); +} diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 0b3a9f8a..928c25d1 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -2619,15 +2619,35 @@ fn build() -> Vec { cmd!("effects.reset", "Reset Effect", [], None, r#"{"clip":id,"index":n}"#, has_seq, |s, p| { let c = clip_p(p, "clip").ok_or_else(|| bad("effects.reset", "need `clip`"))?; let idx = u64_p(p, "index").unwrap_or(0) as usize; - let (fw, fh) = s.active_sequence().map(|q| (q.settings.width, q.settings.height)).unwrap_or((1920, 1080)); + let frame = s.active_sequence().map(|q| (q.settings.width, q.settings.height)).unwrap_or((1920, 1080)); + let ph = s.playhead(); s.edit_sequence("Reset Effect", |q, _, _| { let (_, it) = q.find_item_mut(c).ok_or(filmcraft_edit::EditError::NoItem(c))?; - if let Some(e) = it.effects.get_mut(idx) - && let Some(d) = e.def() - { - let mut fresh = d.instance(); - resolve_auto_points(&mut fresh, (fw, fh), (fw, fh)); - *e = fresh; + let mt = it.source_time_at(ph.clamp(it.start, (it.end() - Tick(1)).max(it.start))); + if let Some(e) = it.effects.get_mut(idx) { + reset_params(e, None, frame, mt); + } + Ok(()) + })?; + Ok(Value::Null) + }), + cmd!("effects.resetParam", "Reset Parameter", [], None, r#"{"clip":id,"effect":str|index,"param":str}"#, has_seq, |s, p| { + let c = clip_p(p, "clip").ok_or_else(|| bad("effects.resetParam", "need `clip`"))?; + let pid = str_p(p, "param").ok_or_else(|| bad("effects.resetParam", "need `param`"))?.to_string(); + let eff = p.get("effect").cloned().unwrap_or(json!("motion")); + let frame = s.active_sequence().map(|q| (q.settings.width, q.settings.height)).unwrap_or((1920, 1080)); + let ph = s.playhead(); + s.edit_sequence("Reset Parameter", |q, _, _| { + let (_, it) = q.find_item_mut(c).ok_or(filmcraft_edit::EditError::NoItem(c))?; + let mt = it.source_time_at(ph.clamp(it.start, (it.end() - Tick(1)).max(it.start))); + let e = match &eff { + Value::Number(n) => it.effects.get_mut(n.as_u64().unwrap_or(0) as usize), + Value::String(sid) => it.effects.iter_mut().find(|e| &e.effect == sid), + _ => None, + } + .ok_or_else(|| bad("effects.resetParam", "no such effect on clip"))?; + if !reset_params(e, Some(&pid), frame, mt) { + return Err(bad("effects.resetParam", format!("no param `{pid}`"))); } Ok(()) })?; @@ -2843,6 +2863,30 @@ fn build() -> Vec { v } +/// Put `e`'s parameters (all of them, or only `only`) back to their defaults, as Premiere's reset +/// buttons do: a parameter that is not animated gets its default as its static value, and an +/// animated one keeps its keyframes and gets the default as a keyframe at media time `mt`. +/// Masks are left alone. Returns whether any parameter was reset. +fn reset_params(e: &mut filmcraft_project::EffectInstance, only: Option<&str>, frame: (u32, u32), mt: Tick) -> bool { + let Some(d) = e.def() else { return false }; + let mut fresh = d.instance(); + resolve_auto_points(&mut fresh, frame, frame); + let mut any = false; + for (id, prm) in fresh.params { + if only.is_some_and(|o| o != id) { + continue; + } + any = true; + match e.params.get_mut(&id) { + Some(old) if old.is_animated() => old.set_at(mt, prm.value), + _ => { + e.params.insert(id, prm); + } + } + } + any +} + fn keyframe_op(s: &mut Session, p: &Value, op: &str) -> Result { let c = clip_p(p, "clip").ok_or_else(|| bad("keyframe", "need `clip`"))?; let pid = str_p(p, "param").ok_or_else(|| bad("keyframe", "need `param`"))?.to_string(); diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 08f899ec..980ef537 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -968,6 +968,7 @@ Reset… Restablecer… Source · {name} Origen · {name} Reset Effect Restablecer efecto + Reset Parameter Restablecer parámetro Save Preset… Guardar ajuste preestablecido… Custom Setup Configuración personalizada Edit… Editar… diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 8dbf4419..f9ec82d0 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -970,6 +970,7 @@ Reset… リセット… Source · {name} ソース · {name} Reset Effect エフェクトをリセット + Reset Parameter パラメーターをリセット Save Preset… プリセットを保存… Custom Setup カスタム設定 Edit… 編集… diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index 731067f9..9e46602c 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -963,6 +963,7 @@ Reset… 重置… Source · {name} 源 · {name} Reset Effect 重置效果 + Reset Parameter 重置参数 Save Preset… 保存预设… Custom Setup 自定义设置 Edit… 编辑… diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 1eeb87b2..e1f3aebb 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -544,6 +544,17 @@ pub(crate) fn param_row( actions.push(("effects.addKeyframe".into(), with_mask(json!({"clip": clip.0, "effect": eff_json, "param": pd.id})))); } } + // reset to the default, under the effect's own reset button (an animated parameter keeps its + // keyframes and gets one at the playhead) + if mask.is_none() { + let rr = Rect::from_center_size(pos2(r.max.x - 14.0, r.center().y), vec2(12.0, 12.0)); + let rresp = ui.interact(rr, egui::Id::new(("param-reset", clip.0, idx, pkey)), Sense::click()).on_hover_text(tl!("Reset Parameter")); + icons::paint(ui.painter(), rr, Icon::Reset, if rresp.hovered() { t.text } else { t.text_dim }); + app.auto.add(&format!("effectControls.{}.{}.reset", e.effect, pkey), rr, "Reset Parameter"); + if rresp.clicked() { + actions.push(("effects.resetParam".into(), json!({"clip": clip.0, "effect": eff_json, "param": pd.id}))); + } + } // keyframes in the lane: draggable diamonds; right-click for interpolation if param.is_animated() { let y = r.center().y; diff --git a/crates/ui-egui/tests/keyframes_ui.rs b/crates/ui-egui/tests/keyframes_ui.rs index 630a8cea..87986f54 100644 --- a/crates/ui-egui/tests/keyframes_ui.rs +++ b/crates/ui-egui/tests/keyframes_ui.rs @@ -184,6 +184,22 @@ fn properties_diamond_adds_and_removes_the_keyframe_at_the_playhead() { assert!(d.find(&format!("{SCALE}.prevKeyframe")).is_none()); } +/// #284: every Effect Controls property has a reset button; on an animated property it puts the +/// default in as a keyframe at the playhead and keeps the other keyframes. +#[test] +fn effect_controls_reset_button_keeps_keyframes() { + let (mut d, clip) = Driver::demo(); + let t0 = d.seek(0.5); + d.click(&format!("{SCALE}.addKeyframe")); + d.exec("effects.setParam", json!({"clip": clip.id, "effect": "motion", "param": "scale", "value": 150.0, "time": t0})); + d.seek(2.5); + d.click("effectControls.motion.scale.reset"); + d.shot("effect-controls-reset"); + let (keys, value) = d.scale(&clip); + assert_eq!(keys.len(), 2, "the keyframe stays and the default is keyed at the playhead: {keys:?}"); + assert_eq!(value, 100.0); +} + /// Crop is not on a clip until it is used: its diamond applies the effect, then adds the keyframe. #[test] fn properties_diamond_applies_crop_first() { From c4e61b3c83720844f000b2cc19c593212c564030 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 17:45:50 +0200 Subject: [PATCH 046/144] #457: Settings > Appearance > UI Scale Problem: FilmCraft had no UI scale preference. The interface always followed the scale the desktop reported (150% on some X11 setups), and only Ctrl+-/Ctrl++ could change it, for the session only. Fix: add appearance.uiScale (Auto, 75%-300%) to Settings > Appearance. A fixed value sets egui's zoom so a point is that many physical pixels whatever the system reports; it is applied when the setting or the system scale changes, so Ctrl+-/Ctrl++ keep working for the session, and Auto gives the system's scale back. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/settings.rs | 23 +++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 22 ++++++++++++++++++++++ crates/ui-egui/src/panels/settings.rs | 24 ++++++++++++++++++++++++ 3 files changed, 69 insertions(+) diff --git a/crates/engine/src/settings.rs b/crates/engine/src/settings.rs index 2f020835..7b0c11dd 100644 --- a/crates/engine/src/settings.rs +++ b/crates/engine/src/settings.rs @@ -104,6 +104,9 @@ pub struct AppearancePrefs { pub accessible_contrast: bool, /// Highlight (accent) colour of selections, focus and primary buttons (`#rrggbb`). pub highlight_color: String, + /// "UI Scale": `auto` (default: the scale the operating system reports) or a fixed + /// percentage of [`UI_SCALES`] (`100` = one point per physical pixel). + pub ui_scale: String, } impl Default for AppearancePrefs { @@ -115,11 +118,19 @@ impl Default for AppearancePrefs { light_theme: "light".into(), accessible_contrast: false, highlight_color: DEFAULT_HIGHLIGHT.into(), + ui_scale: "auto".into(), } } } impl AppearancePrefs { + /// The fixed UI scale (physical pixels per point) "UI Scale" asks for, or `None` for `auto` + /// (and for a value outside [`UI_SCALES`]), which keeps the operating system's scale. + pub fn ui_scale_factor(&self) -> Option { + // `auto` doesn't parse; every other listed value is a positive whole percentage + UI_SCALES.iter().any(|(k, _)| *k == self.ui_scale).then(|| self.ui_scale.parse::().ok()).flatten().map(|pct| pct / 100.0) + } + /// The theme to show (`darkest` | `dark` | `light`): the light or dark theme of the mode. Auto /// follows `system_light` (the operating system's appearance) and is dark without an answer. pub fn shown_theme(&self, system_light: Option) -> &str { @@ -669,6 +680,17 @@ const BIN_OPEN: &[(&str, &str)] = &[("openInPlace", "Open in place"), ("openNewT const PROJECT_OPEN: &[(&str, &str)] = &[("openNewTab", "Open new tab"), ("openNewWindow", "Open in new window")]; const THEMES: &[(&str, &str)] = &[("darkest", "Darkest"), ("dark", "Dark"), ("light", "Light")]; pub const APPEARANCE_MODES: &[(&str, &str)] = &[("auto", "Sync with system"), ("dark", "Dark"), ("light", "Light")]; +pub const UI_SCALES: &[(&str, &str)] = &[ + ("auto", "Auto (system)"), + ("75", "75%"), + ("100", "100%"), + ("125", "125%"), + ("150", "150%"), + ("175", "175%"), + ("200", "200%"), + ("250", "250%"), + ("300", "300%"), +]; pub const DARK_THEMES: &[(&str, &str)] = &[("darkest", "Darkest"), ("dark", "Dark")]; pub const LIGHT_THEMES: &[(&str, &str)] = &[("light", "Light")]; const MIXDOWN: &[(&str, &str)] = &[("front", "Front Only"), ("frontRear", "Front + Rear"), ("frontLfe", "Front + LFE"), ("frontRearLfe", "Front + Rear + LFE")]; @@ -795,6 +817,7 @@ static CATEGORIES: &[Category] = &[ f("appearance.colorTheme", "Color Theme", Kind::Choice(THEMES), true), b("appearance.accessibleContrast", "Accessible color contrast", true), f("appearance.highlightColor", "Highlight Color", Kind::Color, true), + f("appearance.uiScale", "UI Scale", Kind::Choice(UI_SCALES), true), ], }, Category { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 857f42a5..fed5a8fa 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -308,6 +308,8 @@ pub struct FilmcraftApp { watched_render: Option<(u64, Tick)>, /// Settings last applied to the UI (theme, tooltips, frame cache, audio device). applied_prefs: Option, + /// Settings ▸ Appearance ▸ UI Scale last applied, with the system scale it was applied for. + applied_ui_scale: Option<(Option, f32)>, workspace_restored: bool, /// Window ▸ Workspaces: saved layouts ([`dock::WORKSPACES_FILE`] in the data directory). pub workspaces: dock::WorkspacePrefs, @@ -492,6 +494,7 @@ impl FilmcraftApp { gpu: None, watched_render: None, applied_prefs: None, + applied_ui_scale: None, workspace_restored: false, workspaces, menu_workspaces: Default::default(), @@ -551,9 +554,28 @@ impl FilmcraftApp { ctx.global_style_mut(|s| s.interaction.tooltip_delay = delay); } + /// Settings ▸ Appearance ▸ UI Scale: a fixed scale makes a point that many physical pixels, + /// whatever scale the system reports. It is applied only when the setting or the system scale + /// changes, so Ctrl+- / Ctrl++ still zoom the interface for the session; going back to Auto + /// restores the system's scale. + fn apply_ui_scale(&mut self, ctx: &egui::Context) { + let want = self.session.prefs.appearance.ui_scale_factor(); + let native = ctx.native_pixels_per_point().filter(|v| v.is_finite() && *v > 0.0).unwrap_or(1.0); + if self.applied_ui_scale == Some((want, native)) { + return; + } + let prev = self.applied_ui_scale.replace((want, native)); + match want { + Some(scale) => ctx.set_zoom_factor(scale / native), + None if prev.is_some_and(|(p, _)| p.is_some()) => ctx.set_zoom_factor(1.0), + None => {} + } + } + /// Make the UI follow the settings after they change (theme, tooltips, frame cache budget, /// play after rendering, audio device). pub fn apply_prefs(&mut self, ctx: &egui::Context) { + self.apply_ui_scale(ctx); // Appearance Mode ▸ Sync with system: follow the system while running. The host reports // changes as they happen (no polling) and wakes the UI only when the value changes. if self.applied_prefs.is_some() { diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 41395d86..4dd25b64 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -785,6 +785,30 @@ mod tests { use super::*; use crate::theme::ThemeKind; + /// #457: on a desktop that reports 150%, UI Scale 100% makes a point one physical pixel. It is + /// applied once, so Ctrl+- still zooms for the session, and Auto gives the system's scale back. + #[test] + fn ui_scale_overrides_the_system_scale_and_auto_restores_it() { + let ctx = egui::Context::default(); + let mut app = FilmcraftApp::new(filmcraft_engine::Session::default()); + // a frame applying the settings, then the next one, where a new zoom takes effect + let pass = |app: &mut FilmcraftApp| { + let mut input = egui::RawInput::default(); + input.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(1.5); + ctx.run_ui(input.clone(), |ui| app.apply_prefs(ui.ctx())).textures_delta.clear(); + ctx.run_ui(input, |_| {}).textures_delta.clear(); + ctx.zoom_factor() + }; + assert_eq!(pass(&mut app), 1.0, "Auto keeps the system's scale"); + app.session.execute("prefs.set", json!({"key": "appearance.uiScale", "value": "100"})).unwrap(); + assert_eq!(pass(&mut app), 1.0 / 1.5, "100% is one physical pixel per point"); + ctx.set_zoom_factor(0.9 / 1.5); // Ctrl+- + assert_eq!(pass(&mut app), 0.9 / 1.5, "a session zoom isn't undone every frame"); + assert!(app.session.execute("prefs.set", json!({"key": "appearance.uiScale", "value": "130"})).is_err()); + app.session.execute("prefs.set", json!({"key": "appearance.uiScale", "value": "auto"})).unwrap(); + assert_eq!(pass(&mut app), 1.0); + } + #[test] fn appearance_button_cycles_modes_without_losing_theme_choices() { let ctx = egui::Context::default(); From 9e77d9d7868459d947c8d63bf51ea41c016bc890 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 02:48:52 +1100 Subject: [PATCH 047/144] Apply Transform shutter angle when the override is on. The Transform effect listed a shutter angle but had no override, so motion blur never used it. Checking Use Effect Shutter Angle now samples the animated transform across that angle. --- crates/project/src/effect.rs | 3 ++- crates/render/src/vfx/distort.rs | 45 ++++++++++++++++++++++++++++++++ crates/render/src/vfx/mod.rs | 1 + crates/render/src/vfx/tests.rs | 23 +++++++++++++++- 4 files changed, 70 insertions(+), 2 deletions(-) diff --git a/crates/project/src/effect.rs b/crates/project/src/effect.rs index 1abd4e66..200fdee1 100644 --- a/crates/project/src/effect.rs +++ b/crates/project/src/effect.rs @@ -619,7 +619,8 @@ fn build_effects() -> Vec { ang("skew_axis", "Skew Axis", 0.0), ang("rotation", "Rotation", 0.0), f("opacity", "Opacity", 100.0, 0.0, 100.0, ""), - f("shutter_angle", "Shutter Angle", 0.0, 0.0, 360.0, ""), + b("shutter_override", "Use Effect Shutter Angle", false), + f("shutter_angle", "Shutter Angle", 180.0, 0.0, 360.0, ""), ], ), video("mirror", "Mirror", DISTORT, vec![pt("center", "Reflection Center", f64::NAN, f64::NAN), ang("angle", "Reflection Angle", 0.0)]), diff --git a/crates/render/src/vfx/distort.rs b/crates/render/src/vfx/distort.rs index 6dd33993..95647857 100644 --- a/crates/render/src/vfx/distort.rs +++ b/crates/render/src/vfx/distort.rs @@ -231,6 +231,51 @@ pub fn rotate_3d(img: &mut Image, e: &EffectInstance, cx: &FxCtx) { }); } +/// Transform: affine place, with optional shutter-angle motion blur when the override is on. +pub fn transform_fx(img: &mut Image, e: &EffectInstance, cx: &FxCtx) { + let fps = cx.env.map_or(24.0, |env| env.frame_rate()).max(1.0); + let angle = fv(e, "shutter_angle", cx); + let n = if bv(e, "shutter_override") && angle > 0.5 { 7 } else { 1 }; + let span = (angle as f64 / 360.0) / fps; + motion_blurred(img, n, |im, k| { + let t = cx.t + Tick::from_seconds_f64(k * span); + let cx2 = FxCtx { + t, + px_scale: cx.px_scale, + seconds: cx.seconds + k * span, + timecode: cx.timecode, + clip_name: cx.clip_name, + project: cx.project, + env: cx.env, + working: cx.working, + }; + let anchor = crate::effects::point(e, "anchor", &cx2, im); + let pos = crate::effects::point(e, "position", &cx2, im); + let sh = crate::effects::f(e, "scale_height", &cx2) as f64 / 100.0; + let sw = if crate::effects::b(e, "uniform_scale") { sh } else { crate::effects::f(e, "scale_width", &cx2) as f64 / 100.0 }; + let rot = crate::effects::f(e, "rotation", &cx2) as f64; + let skew = (crate::effects::f(e, "skew", &cx2) as f64).to_radians().tan(); + let skew_axis = crate::effects::f(e, "skew_axis", &cx2) as f64; + let op = crate::effects::f(e, "opacity", &cx2) / 100.0; + let sk = Affine::rotate_deg(skew_axis) + .then_apply(&Affine { a: 1.0, b: 0.0, c: skew, d: 1.0, e: 0.0, f: 0.0 }) + .then_apply(&Affine::rotate_deg(-skew_axis)); + let m = Affine::translate(pos.x, pos.y) + .then_apply(&Affine::rotate_deg(rot)) + .then_apply(&sk) + .then_apply(&Affine::scale(sw, sh)) + .then_apply(&Affine::translate(-anchor.x, -anchor.y)); + affine_warp(im, &m); + if op < 1.0 - 1e-5 { + im.px.par_chunks_mut(4).for_each(|p| { + for v in p.iter_mut() { + *v *= op; + } + }); + } + }); +} + /// Grow / Shrink: scale animated across the clip from Scale From to Scale To. pub fn grow(img: &mut Image, e: &EffectInstance, cx: &FxCtx) { let p = ease(chv(e, "easing"), clip_progress(cx)); diff --git a/crates/render/src/vfx/mod.rs b/crates/render/src/vfx/mod.rs index bdca8e52..9c7c966a 100644 --- a/crates/render/src/vfx/mod.rs +++ b/crates/render/src/vfx/mod.rs @@ -484,6 +484,7 @@ pub fn apply(img: &mut Image, e: &EffectInstance, cx: &FxCtx) -> bool { "spin" => distort::spin(img, e, cx), "wiggle" => distort::wiggle(img, e, cx), "camera_shake" => distort::camera_shake(img, e, cx), + "transform" if bv(e, "shutter_override") && fv(e, "shutter_angle", cx) > 0.5 => distort::transform_fx(img, e, cx), "spacer" => distort::spacer(img, e, cx), "clone" => distort::clone_fx(img, e, cx), "auto_align" => distort::auto_align(img, e, cx), diff --git a/crates/render/src/vfx/tests.rs b/crates/render/src/vfx/tests.rs index 6a00196d..d6bcb40d 100644 --- a/crates/render/src/vfx/tests.rs +++ b/crates/render/src/vfx/tests.rs @@ -4,7 +4,7 @@ use std::sync::Arc; -use filmcraft_project::{EffectKind, ParamKind, find_effect}; +use filmcraft_project::{EffectKind, Interpolation, Keyframe, Param, ParamKind, find_effect}; use super::*; @@ -759,6 +759,27 @@ fn transform_presets_animate_over_the_clip() { assert_eq!(text::burnin_text(&e2, &mid), "FILE 24"); } +#[test] +fn transform_shutter_override_blurs_moving_position() { + let mut e = inst("transform"); + set(&mut e, "shutter_override", ParamValue::Bool(true)); + setf(&mut e, "shutter_angle", 180.0); + let a = Keyframe::new(Tick::ZERO, ParamValue::Vec2(filmcraft_geom::Vec2::new(8.0, 16.0))); + let mut b = Keyframe::new(Tick::from_seconds_f64(1.0), ParamValue::Vec2(filmcraft_geom::Vec2::new(40.0, 16.0))); + b.interp = Interpolation::Linear; + e.params.insert("position".into(), Param { value: ParamValue::Vec2(filmcraft_geom::Vec2::new(8.0, 16.0)), keyframes: vec![a, b] }); + let img = picture(32, 32); + let cx = FxCtx { t: Tick::from_seconds_f64(0.5), seconds: 0.5, ..cx() }; + let sharp = { + let mut off = e.clone(); + set(&mut off, "shutter_override", ParamValue::Bool(false)); + run("transform", &off, &img, &cx) + }; + let blurred = run("transform", &e, &img, &cx); + let delta = max_diff(&sharp, &blurred); + assert!(delta > 1e-3, "shutter override left the Transform picture unchanged ({delta})"); +} + #[test] fn clone_auto_align_and_rounded_crop() { let img = picture(40, 20); From 0f08798af0e5e5ef47e99776601ef955339bfc52 Mon Sep 17 00:00:00 2001 From: Abdelkabir Ouadoukou Date: Sat, 10 Oct 2026 15:54:00 +0000 Subject: [PATCH 048/144] platform: look up kVTCompressionPropertyKey_ConstantBitRate at run time --- crates/platform/src/videotoolbox_encode.rs | 38 +++++++++++++++++----- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/crates/platform/src/videotoolbox_encode.rs b/crates/platform/src/videotoolbox_encode.rs index 4e9130dd..5edf4767 100644 --- a/crates/platform/src/videotoolbox_encode.rs +++ b/crates/platform/src/videotoolbox_encode.rs @@ -19,7 +19,7 @@ //! for H.264, VPS / SPS / PPS and VideoToolbox's own `hvcC` record for HEVC) come with the first //! compressed frame, and the muxer asks for them after the first group of frames. -use std::ffi::{c_int, c_void}; +use std::ffi::{CStr, c_char, c_int, c_void}; use std::panic::AssertUnwindSafe; use std::ptr::NonNull; use std::sync::{Mutex, PoisonError}; @@ -39,10 +39,10 @@ use objc2_core_video::{ use objc2_video_toolbox::{ VTCompressionSession, VTEncodeInfoFlags, VTSessionCopyProperty, VTSessionSetProperty, kVTCompressionPropertyKey_AllowFrameReordering, kVTCompressionPropertyKey_AllowOpenGOP, kVTCompressionPropertyKey_AverageBitRate, kVTCompressionPropertyKey_ColorPrimaries, - kVTCompressionPropertyKey_ConstantBitRate, kVTCompressionPropertyKey_DataRateLimits, kVTCompressionPropertyKey_ExpectedFrameRate, - kVTCompressionPropertyKey_MaxKeyFrameInterval, kVTCompressionPropertyKey_ProfileLevel, kVTCompressionPropertyKey_RealTime, - kVTCompressionPropertyKey_TransferFunction, kVTCompressionPropertyKey_UsingHardwareAcceleratedVideoEncoder, kVTCompressionPropertyKey_YCbCrMatrix, - kVTProfileLevel_H264_Baseline_AutoLevel, kVTProfileLevel_H264_High_AutoLevel, kVTProfileLevel_H264_Main_AutoLevel, kVTProfileLevel_HEVC_Main_AutoLevel, + kVTCompressionPropertyKey_DataRateLimits, kVTCompressionPropertyKey_ExpectedFrameRate, kVTCompressionPropertyKey_MaxKeyFrameInterval, + kVTCompressionPropertyKey_ProfileLevel, kVTCompressionPropertyKey_RealTime, kVTCompressionPropertyKey_TransferFunction, + kVTCompressionPropertyKey_UsingHardwareAcceleratedVideoEncoder, kVTCompressionPropertyKey_YCbCrMatrix, kVTProfileLevel_H264_Baseline_AutoLevel, + kVTProfileLevel_H264_High_AutoLevel, kVTProfileLevel_H264_Main_AutoLevel, kVTProfileLevel_HEVC_Main_AutoLevel, kVTVideoEncoderSpecification_RequireHardwareAcceleratedVideoEncoder, }; @@ -421,6 +421,28 @@ fn set_bool(session: &VTCompressionSession, key: &CFString, on: bool) -> i32 { set_value(session, key, CFBoolean::new(on).as_ref()) } +unsafe extern "C" { + fn dlsym(handle: *mut c_void, symbol: *const c_char) -> *mut c_void; +} + +/// `kVTCompressionPropertyKey_ConstantBitRate`, or `None` on a macOS that does not have it. +/// +/// The key exists from macOS 13. Naming it as a linked symbol makes dyld refuse to start the whole +/// app on macOS 11 and 12 ("Symbol not found"), although the encoder already falls back to an +/// average bitrate when the key is unavailable. So it is looked up at run time instead. +fn constant_bitrate_key() -> Option<&'static CFString> { + // `RTLD_DEFAULT` on macOS: search every image loaded into the process, VideoToolbox included. + const RTLD_DEFAULT: *mut c_void = -2isize as *mut c_void; + const NAME: &CStr = c"kVTCompressionPropertyKey_ConstantBitRate"; + // SAFETY: `NAME` is a valid NUL-terminated string; `dlsym` only reads it. + let symbol = unsafe { dlsym(RTLD_DEFAULT, NAME.as_ptr()) }; + let slot = NonNull::new(symbol)?.cast::<*const CFString>(); + // SAFETY: the symbol is a `CFStringRef` constant exported by VideoToolbox, so `slot` points at a + // pointer to an immutable CFString that lives as long as the framework stays loaded (the whole + // process); it is read once and never written. + unsafe { slot.read().as_ref() } +} + fn set_value(session: &VTCompressionSession, key: &CFString, value: &CFType) -> i32 { // SAFETY: the session is valid, `key` is a framework constant and `value` a live CF object. unsafe { VTSessionSetProperty(session.as_ref(), key, Some(value)) } @@ -527,14 +549,14 @@ impl VtEncoder { let _ = set_value(session, fps_key, CFNumber::new_f64(fps).as_ref()); // SAFETY: reading immutable framework constants. - let (avg_key, limits_key, cbr_key) = - unsafe { (kVTCompressionPropertyKey_AverageBitRate, kVTCompressionPropertyKey_DataRateLimits, kVTCompressionPropertyKey_ConstantBitRate) }; + let (avg_key, limits_key) = unsafe { (kVTCompressionPropertyKey_AverageBitRate, kVTCompressionPropertyKey_DataRateLimits) }; + let cbr_key = constant_bitrate_key(); let bps = |kbps: u32| i32::try_from(u64::from(kbps).saturating_mul(1000)).unwrap_or(i32::MAX); match c.rate { VtRate::Cbr { kbps } => { // true constant bitrate where the OS offers it (macOS 13+); otherwise an average // with a one-second ceiling at the same rate - if set_value(session, cbr_key, CFNumber::new_i32(bps(kbps)).as_ref()) != 0 { + if !cbr_key.is_some_and(|key| set_value(session, key, CFNumber::new_i32(bps(kbps)).as_ref()) == 0) { require(set_value(session, avg_key, CFNumber::new_i32(bps(kbps)).as_ref()), "the bitrate")?; let limits = CFArray::::from_objects(&[CFNumber::new_i32(bps(kbps) / 8).as_ref(), CFNumber::new_f64(1.0).as_ref()]); let _ = set_value(session, limits_key, limits.as_ref()); From 4b67c44dbe9a9dc78cacb30cb2bf04e1f12a3403 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 18:12:54 +0200 Subject: [PATCH 049/144] #342: ProRes 4444 and 4444 XQ export, with alpha Problem: the ProRes encoder already codes 4444 / 4444 XQ (optionally with alpha), but export only knew the four 422 flavours: any other proresProfile fell back to HQ and the encoder was always fed a 4:2:2 frame, so graphics could not round-trip with their alpha. Fix: proresProfile accepts 4444 and 4444xq. The ProRes export encoder builds a 4:4:4 frame for them and, with Include Alpha Channel, codes the straight alpha of the unflattened picture (ExportSettings:: supports_alpha / keeps_alpha). Export Settings offers both profiles and the alpha checkbox for them; new presets Apple ProRes 4444, 4444 with Alpha and 4444 XQ; summary text and the MXF coding label name the profile (and the MXF descriptor keeps its 12-bit 4:4:4 depth). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands.rs | 2 +- crates/engine/src/export_tests.rs | 9 ++- crates/export/src/lib.rs | 75 ++++++++++++++++++++---- crates/export/src/mxf_out.rs | 7 +-- crates/export/src/pipeline.rs | 4 +- crates/export/src/presets.rs | 8 +++ crates/export/src/settings.rs | 19 +++++- crates/export/src/tests.rs | 45 ++++++++++++++ crates/ui-egui/src/panels/export_mode.rs | 7 ++- 9 files changed, 154 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 0b3a9f8a..ec5385be 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1180,7 +1180,7 @@ fn build() -> Vec { "Media…", ["File", "Export"], None, - r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass"?,"hardwareEncoding":"off|auto"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"alpha":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#, + r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass"?,"hardwareEncoding":"off|auto"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"alpha":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq|4444|4444xq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#, has_seq, crate::export_tools::export_media ), diff --git a/crates/engine/src/export_tests.rs b/crates/engine/src/export_tests.rs index 2757ebba..a1f3f231 100644 --- a/crates/engine/src/export_tests.rs +++ b/crates/engine/src/export_tests.rs @@ -202,9 +202,16 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() { "proxy" => ("Proxy", 45.0), "lt" => ("LT", 102.0), "standard" => ("Standard", 147.0), + "4444" => ("4444", 330.0), + "4444xq" => ("XQ", 500.0), _ => ("HQ", 220.0), }; assert_eq!(v["profile"], profile, "{name}"); + if matches!(profile, "4444" | "XQ") { + let pix = v["pix_fmt"].as_str().unwrap_or_default(); + let alpha = settings["alpha"].as_bool().unwrap_or(false); + assert!(pix.contains("444") && pix.starts_with("yuva") == alpha, "{name}: {pix}"); + } assert!(vbr <= mbps * px * 1.5, "{name}: {vbr:.1} Mb/s vs nominal {:.1}", mbps * px); assert_eq!(a["codec_name"], "pcm_s24le", "{name}"); } @@ -303,7 +310,7 @@ fn user_presets_persist_with_favourites_import_and_export() { let favs = s.execute("export.presets.list", json!({"favorites": true})).unwrap()["presets"].as_array().unwrap().len(); assert_eq!(favs, 2); let hits = s.execute("export.presets.list", json!({"query": "prores"})).unwrap()["presets"].as_array().unwrap().len(); - assert_eq!(hits, 5, "four QuickTime ProRes presets and MXF OP1a ProRes 422 HQ"); + assert_eq!(hits, 8, "seven QuickTime ProRes presets and MXF OP1a ProRes 422 HQ"); // persisted: a fresh session with the same data directory sees both let mut s2 = demo(); s2.export_presets.set_dir(&dir.0); diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 312d0301..05196d72 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -239,7 +239,7 @@ pub struct ExportSettings { /// `progress.total`, `finished` and the final status; this call only adds to `done`. #[serde(default)] pub part_of_batch: bool, - /// ProRes flavour: `proxy`, `lt`, `standard` or `hq` (empty = HQ). + /// ProRes flavour: `proxy`, `lt`, `standard`, `hq`, `4444` or `4444xq` (empty = HQ). #[serde(default)] pub prores_profile: String, /// DNxHR profile: `lb`, `sq`, `hq` or `hqx` (empty = HQ). @@ -256,8 +256,9 @@ pub struct ExportSettings { /// sequence's HDR space and signal it (VUI / `colr` / `mdcv` / `clli` / SEI). #[serde(default)] pub sdr: bool, - /// Keep the alpha channel in PNG and TIFF sequences (straight alpha, Premiere's "Include - /// Alpha Channel"). Off, every frame is flattened over black (#160). Other formats ignore it. + /// Keep the alpha channel in PNG and TIFF sequences and ProRes 4444 / 4444 XQ QuickTime movies + /// (straight alpha, Premiere's "Include Alpha Channel"). Off, every frame is flattened over + /// black (#160). Other formats ignore it. #[serde(default)] pub alpha: bool, /// Output frame size (None = Match Source: the sequence size times `scale`). @@ -986,6 +987,8 @@ fn aac_factory(_format: Format, sample_rate: u32, channels: u32, s: &ExportSetti struct ProResEncoder { enc: filmcraft_prores::Encoder, profile: filmcraft_prores::Profile, + /// Code the frames' alpha channel (4444 profiles with Include Alpha Channel). + alpha: bool, w: u32, h: u32, rate: FrameRate, @@ -1002,13 +1005,19 @@ impl VideoEncoder for ProResEncoder { self.rate.num as u32 } fn encode(&mut self, f: &EncoderFrame) -> Result> { - let mut fr = filmcraft_prores::Frame::new(f.width, f.height, filmcraft_prores::ChromaFormat::Yuv422, 10, false); - timed(Stage::Convert, || match f.hdr { - Some(rgb) => { - let (kr, kb) = self.signal.kr_kb(); - rgbf_to_yuv422_10(rgb, f.width as usize, f.height as usize, kr, kb, &mut fr.y, &mut fr.cb, &mut fr.cr) + let chroma = self.profile.chroma(); + let mut fr = filmcraft_prores::Frame::new(f.width, f.height, chroma, 10, self.alpha); + let (w, h) = (f.width as usize, f.height as usize); + timed(Stage::Convert, || match (chroma, f.hdr) { + (filmcraft_prores::ChromaFormat::Yuv444, hdr) => { + let (kr, kb) = if hdr.is_some() { self.signal.kr_kb() } else { (0.2126, 0.0722) }; + to_yuv444_10(f.rgba, hdr, w, h, kr, kb, &mut fr.y, &mut fr.cb, &mut fr.cr, fr.alpha.as_deref_mut()) } - None => rgba_to_yuv422_10(f.rgba, f.width as usize, f.height as usize, &mut fr.y, &mut fr.cb, &mut fr.cr), + (_, Some(rgb)) => { + let (kr, kb) = self.signal.kr_kb(); + rgbf_to_yuv422_10(rgb, w, h, kr, kb, &mut fr.y, &mut fr.cb, &mut fr.cr) + } + (_, None) => rgba_to_yuv422_10(f.rgba, w, h, &mut fr.y, &mut fr.cb, &mut fr.cr), }); let data = self.enc.encode(&fr).map_err(|e| ExportError::Encode(e.to_string()))?; Ok(vec![EncodedPacket { data, key: true, duration: self.rate.den as u32, composition_offset: 0 }]) @@ -1018,6 +1027,47 @@ impl VideoEncoder for ProResEncoder { } } +/// Limited-range 10-bit 4:4:4 with matrix (Kr, Kb) from straight RGBA8, or from encoded R'G'B' +/// floats (3 per pixel) when `hdr` is given. `alpha` (full range, 0 = transparent) takes the +/// RGBA8 alpha; it stays opaque for float input, which carries none. +#[allow(clippy::too_many_arguments)] +pub fn to_yuv444_10( + rgba: &[u8], + hdr: Option<&[f32]>, + w: usize, + h: usize, + kr: f32, + kb: f32, + y: &mut [u16], + cb: &mut [u16], + cr: &mut [u16], + alpha: Option<&mut [u16]>, +) { + let (w, n) = (w.max(1), w.saturating_mul(h)); + let kg = 1.0 - kr - kb; + let (sb, sr) = (2.0 * (1.0 - kb), 2.0 * (1.0 - kr)); + let rgb_at = |i: usize| -> [f32; 3] { + match hdr { + Some(f) => f.get(i * 3..i * 3 + 3).map_or([0.0; 3], |p| [p[0], p[1], p[2]]), + None => rgba.get(i * 4..i * 4 + 3).map_or([0.0; 3], |p| [p[0] as f32 / 255.0, p[1] as f32 / 255.0, p[2] as f32 / 255.0]), + } + }; + y.par_chunks_mut(w).zip(cb.par_chunks_mut(w).zip(cr.par_chunks_mut(w))).enumerate().for_each(|(row, (yr, (cbr, crr)))| { + for (x, (yv, (cbv, crv))) in yr.iter_mut().zip(cbr.iter_mut().zip(crr.iter_mut())).enumerate() { + let [r, g, b] = rgb_at(row * w + x); + let yy = kr * r + kg * g + kb * b; + *yv = (64.0 + 876.0 * yy).round().clamp(4.0, 1019.0) as u16; + *cbv = (512.0 + 896.0 * (b - yy) / sb).round().clamp(4.0, 1019.0) as u16; + *crv = (512.0 + 896.0 * (r - yy) / sr).round().clamp(4.0, 1019.0) as u16; + } + }); + if let (Some(a), None) = (alpha, hdr) { + for (i, dst) in a.iter_mut().enumerate().take(n) { + *dst = rgba.get(i * 4 + 3).map_or(1023, |&v| ((v as u32 * 1023 + 127) / 255) as u16); + } + } +} + /// BT.709 limited-range 10-bit 4:2:2 from straight RGBA8 (chroma averaged horizontally). pub fn rgba_to_yuv422_10(rgba: &[u8], w: usize, h: usize, y: &mut [u16], cb: &mut [u16], cr: &mut [u16]) { let cw = w.div_ceil(2); @@ -1051,6 +1101,8 @@ pub fn prores_profile(name: &str) -> filmcraft_prores::Profile { "proxy" => Profile::Proxy, "lt" => Profile::Lt, "standard" | "422" => Profile::Standard, + "4444" => Profile::P4444, + "4444xq" | "4444 xq" | "4444-xq" | "xq" => Profile::P4444Xq, _ => Profile::Hq, } } @@ -1089,7 +1141,10 @@ fn prores_factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSet if s.signal.is_hdr() { cfg.color = filmcraft_prores::ColorInfo { primaries: s.signal.primaries, transfer: s.signal.transfer, matrix: s.signal.matrix }; } - Ok(Box::new(ProResEncoder { enc: filmcraft_prores::Encoder::with_config(cfg), profile, w, h, rate, signal: s.signal }) as Box) + let alpha = s.keeps_alpha(); + cfg.encode_alpha = alpha; + let enc = filmcraft_prores::Encoder::with_config(cfg); + Ok(Box::new(ProResEncoder { enc, profile, alpha, w, h, rate, signal: s.signal }) as Box) }) } diff --git a/crates/export/src/mxf_out.rs b/crates/export/src/mxf_out.rs index 24233e7a..ecd8ede2 100644 --- a/crates/export/src/mxf_out.rs +++ b/crates/export/src/mxf_out.rs @@ -38,7 +38,9 @@ fn picture(settings: &ExportSettings, w: u32, h: u32) -> PictureDesc { Profile::Proxy => 1, Profile::Lt => 2, Profile::Standard => 3, - _ => 4, + Profile::Hq => 4, + Profile::P4444 => 5, + Profile::P4444Xq => 6, }, } } @@ -53,9 +55,6 @@ fn picture(settings: &ExportSettings, w: u32, h: u32) -> PictureDesc { _ => PictureCoding::Vc3 { cid: crate::dnx_profile(&settings.dnx_profile).cid() }, }; let mut d = PictureDesc::new(coding, w, h); - if matches!(coding, PictureCoding::ProRes { .. }) { - d.depth = 10; - } d.color = match settings.signal { s if s == ColorSignal::PQ => ColorSpace::Rec2020Pq, s if s == ColorSignal::HLG => ColorSpace::Rec2020Hlg, diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index 306aef2f..78503d00 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -12,7 +12,7 @@ use filmcraft_time::FrameRate; use rayon::prelude::*; use crate::settings::{ExportEffects, Placement, Scaling, TextOverlay}; -use crate::{ExportError, ExportSettings, Format, FrameRenderer, GpuRendering, Result, build_frame_renderer, note_gpu_fallback, note_gpu_frame}; +use crate::{ExportError, ExportSettings, FrameRenderer, GpuRendering, Result, build_frame_renderer, note_gpu_fallback, note_gpu_frame}; /// Where the rendered picture lands in the output frame. #[derive(Clone, Copy, Debug, PartialEq)] @@ -201,7 +201,7 @@ impl Pipeline { geom, opts, hdr_out, - alpha: settings.alpha && matches!(settings.format, Format::PngSequence | Format::TiffSequence), + alpha: settings.keeps_alpha(), out_tf, effects, overlay, diff --git a/crates/export/src/presets.rs b/crates/export/src/presets.rs index 3aa37d05..4323de77 100644 --- a/crates/export/src/presets.rs +++ b/crates/export/src/presets.rs @@ -122,6 +122,14 @@ pub fn builtin_presets() -> Vec { p("Apple ProRes 422", bc, "QuickTime, ProRes 422, 24-bit 48 kHz PCM", mov(Format::ProRes, "standard")), p("Apple ProRes 422 LT", bc, "QuickTime, ProRes 422 LT, 24-bit 48 kHz PCM", mov(Format::ProRes, "lt")), p("Apple ProRes 422 Proxy", bc, "QuickTime, ProRes 422 Proxy, 24-bit 48 kHz PCM", mov(Format::ProRes, "proxy")), + p("Apple ProRes 4444", bc, "QuickTime, ProRes 4444 (4:4:4), 24-bit 48 kHz PCM", mov(Format::ProRes, "4444")), + p( + "Apple ProRes 4444 with Alpha", + bc, + "QuickTime, ProRes 4444 (4:4:4) with alpha channel, 24-bit 48 kHz PCM", + ExportSettings { alpha: true, ..mov(Format::ProRes, "4444") }, + ), + p("Apple ProRes 4444 XQ", bc, "QuickTime, ProRes 4444 XQ (4:4:4), 24-bit 48 kHz PCM", mov(Format::ProRes, "4444xq")), p("Avid DNxHR HQ", bc, "QuickTime, DNxHR HQ (8-bit 4:2:2), 24-bit 48 kHz PCM", mov(Format::DnxHr, "hq")), p("Avid DNxHR SQ", bc, "QuickTime, DNxHR SQ (8-bit 4:2:2), 24-bit 48 kHz PCM", mov(Format::DnxHr, "sq")), p("Avid DNxHR LB", bc, "QuickTime, DNxHR LB (8-bit 4:2:2), 24-bit 48 kHz PCM", mov(Format::DnxHr, "lb")), diff --git a/crates/export/src/settings.rs b/crates/export/src/settings.rs index 8e8fc7ef..8b761693 100644 --- a/crates/export/src/settings.rs +++ b/crates/export/src/settings.rs @@ -374,6 +374,21 @@ impl ExportSettings { if self.format.is_mxf() { self.mxf_video_codec.encoder_format() } else { self.format } } + /// Whether the output can carry an alpha channel ([`ExportSettings::alpha`]): PNG and TIFF + /// sequences and ProRes 4444 / 4444 XQ QuickTime movies. + pub fn supports_alpha(&self) -> bool { + match self.format { + Format::PngSequence | Format::TiffSequence => true, + Format::ProRes => crate::prores_profile(&self.prores_profile).chroma() == filmcraft_prores::ChromaFormat::Yuv444, + _ => false, + } + } + + /// Whether the exported frames keep their alpha channel instead of being flattened over black. + pub fn keeps_alpha(&self) -> bool { + self.alpha && self.supports_alpha() + } + /// Whether the output is a numbered image sequence. pub fn is_image_sequence(&self) -> bool { matches!(self.format, Format::PngSequence | Format::TiffSequence | Format::BmpSequence) @@ -540,7 +555,9 @@ impl ExportSettings { Profile::Proxy => ", ProRes 422 Proxy", Profile::Lt => ", ProRes 422 LT", Profile::Standard => ", ProRes 422", - _ => ", ProRes 422 HQ", + Profile::Hq => ", ProRes 422 HQ", + Profile::P4444 => ", ProRes 4444", + Profile::P4444Xq => ", ProRes 4444 XQ", } } Format::DnxHr => v += &format!(", DNxHR {}", if self.dnx_profile.is_empty() { "HQ".into() } else { self.dnx_profile.to_ascii_uppercase() }), diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs index 656ddd9f..ae8e9fd6 100644 --- a/crates/export/src/tests.rs +++ b/crates/export/src/tests.rs @@ -101,6 +101,51 @@ fn prores_export_roundtrip() { assert!(f[0] > 240 && f[1] < 15 && f[2] < 15, "{:?}", &f[..4]); } +/// ProRes 4444 / 4444 XQ export (#342): 4:4:4 frames under the `ap4h` / `ap4x` codes, and with +/// Include Alpha Channel the picture's alpha survives instead of being flattened over black. +#[test] +fn prores_4444_export_roundtrip() { + let (p, seq, m) = project(); + for (profile, label) in [("4444", "ProRes 4444"), ("4444xq", "ProRes 4444 XQ")] { + let path = tmp(&format!("pr_{profile}.mov")); + let s = ExportSettings { format: Format::ProRes, path: path.clone(), prores_profile: profile.into(), ..Default::default() }; + export(&p, seq, &s, &m, &Progress::default()).unwrap(); + let bytes: Arc<[u8]> = std::fs::read(&path).unwrap().into(); + let src = filmcraft_codecs::open_bytes("pr.mov", bytes).unwrap(); + let codec = src.info().video.as_ref().unwrap().codec.clone(); + assert!(codec.contains(label), "{profile}: {codec}"); + let f = src.video_frame(FrameRequest::full(Tick(TICKS_PER_SECOND / 3))).unwrap(); + assert!(f.format_label().contains("4:4:4"), "{profile}: {}", f.format_label()); + let px = f.to_rgba8(); + assert!(px[0] > 240 && px[1] < 15 && px[2] < 15 && px[3] == 255, "{profile}: {:?}", &px[..4]); + } + + // a half-transparent matte keeps its alpha with Include Alpha Channel, and only then + let mut p = (*p).clone(); + let half = GeneratorSource::new(Generator::ColorMatte { color: [1.0, 0.0, 0.0, 0.5] }, 320, 180, FrameRate::FPS_24, Tick(2 * TICKS_PER_SECOND)); + let item = p.sequence(seq).unwrap().video_tracks[0].items[0].item; + if let ItemKind::Media(mc) = &mut p.item_mut(item).unwrap().kind { + mc.media = MediaRef::Generator(half.generator.clone()); + } + let mut m = m; + m.0.insert(item, Arc::new(half)); + let p = Arc::new(p); + for alpha in [true, false] { + let path = tmp(&format!("pr_4444_alpha_{alpha}.mov")); + let s = ExportSettings { format: Format::ProRes, path: path.clone(), prores_profile: "4444".into(), alpha, ..Default::default() }; + assert!(s.keeps_alpha() == alpha); + export(&p, seq, &s, &m, &Progress::default()).unwrap(); + let bytes: Arc<[u8]> = std::fs::read(&path).unwrap().into(); + let src = filmcraft_codecs::open_bytes("pr.mov", bytes).unwrap(); + let px = src.video_frame(FrameRequest::full(Tick(TICKS_PER_SECOND / 3))).unwrap().to_rgba8(); + if alpha { + assert!((px[3] as i32 - 128).abs() <= 3, "alpha kept: {:?}", &px[..4]); + } else { + assert_eq!(px[3], 255, "flattened: {:?}", &px[..4]); + } + } +} + #[test] fn dnxhr_export_roundtrip() { let (p, seq, m) = project(); diff --git a/crates/ui-egui/src/panels/export_mode.rs b/crates/ui-egui/src/panels/export_mode.rs index b66ab552..c93f5ebc 100644 --- a/crates/ui-egui/src/panels/export_mode.rs +++ b/crates/ui-egui/src/panels/export_mode.rs @@ -606,7 +606,7 @@ fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T } }); } - if matches!(s.format, Format::PngSequence | Format::TiffSequence) { + if s.supports_alpha() { row(ui, t, tl!("Alpha"), |ui| { check(ui, reg, "export.video.alpha", &mut s.alpha, tl!("Include Alpha Channel")); }); @@ -700,8 +700,9 @@ fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T } Format::ProRes => { row(ui, t, tl!("Profile"), |ui| { - let keys = ["proxy", "lt", "standard", "hq"]; - let labels = ["Apple ProRes 422 Proxy", "Apple ProRes 422 LT", "Apple ProRes 422", "Apple ProRes 422 HQ"]; + let keys = ["proxy", "lt", "standard", "hq", "4444", "4444xq"]; + let labels = + ["Apple ProRes 422 Proxy", "Apple ProRes 422 LT", "Apple ProRes 422", "Apple ProRes 422 HQ", "Apple ProRes 4444", "Apple ProRes 4444 XQ"]; let cur = keys.iter().position(|k| *k == s.prores_profile).unwrap_or(3); if let Some(i) = combo(ui, reg, "export.video.proresProfile", labels[cur], &opts(&labels), 220.0) { s.prores_profile = keys[i].into(); From 5a243ea8389a1da50563b0fe8d81e07996aaa2ae Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 18:32:12 +0200 Subject: [PATCH 050/144] #239: Position keyframes get the value and velocity graphs Problem: Effect Controls only offered the graph twirl-down for scalar (Float) parameters. Position is a point (Vec2), so animating it showed keyframes but never the value/velocity graphs that Scale has. Fix: point parameters are graphable too. The value graph draws an X and a Y curve with a draggable keyframe on each (a drag changes only that coordinate); the influence handles sit on the X curve, since both coordinates share them; the velocity graph shows the speed along the path. NaN "auto" coordinates are skipped instead of drawn. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/effect_controls.rs | 224 +++++++++++++------ 1 file changed, 154 insertions(+), 70 deletions(-) diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 1eeb87b2..e3313a14 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -17,6 +17,8 @@ const RULER_H: f32 = 24.0; const CLIP_BAR_H: f32 = 18.0; /// Distance between the keyframe navigator's buttons (◀ ◆ ▶). const NAV_STEP: f32 = 16.0; +/// The Y curve of a point parameter's value graph (X uses the accent colour). +const Y_CURVE: Color32 = Color32::from_rgb(0x60, 0xc0, 0x80); /// Properties panel: the keyframe diamonds' column, from a row's right edge (the arrows sit one /// [`NAV_STEP`] to either side of it, and a section's reset button above it). const PROPS_NAV_X: f32 = 26.0; @@ -207,7 +209,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { if app.ui.expanded_fx.contains(&graph_key(clip, idx, pd.id)) && let Some(param) = e.params.get(pd.id) && param.is_animated() - && matches!(param.value, ParamValue::Float(_)) + && graphable(¶m.value) { graph_rows(app, bui, body, clip, idx, None, pd, param, &lane, it, &mut actions); } @@ -422,8 +424,8 @@ pub(crate) fn param_row( }; let (r, _) = ui.allocate_exact_size(vec2(body.width(), ROW_H), Sense::hover()); let mut x = r.min.x + 26.0; - // twirl-down for the value/velocity graphs (animated scalar params) - if param.is_animated() && matches!(param.value, ParamValue::Float(_)) { + // twirl-down for the value/velocity graphs (animated scalar and point params) + if param.is_animated() && graphable(¶m.value) { let key = graph_key(clip, idx, pkey); let open = app.ui.expanded_fx.contains(&key); let tw = Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(12.0, 12.0)); @@ -887,7 +889,22 @@ pub(crate) fn graph_key(clip: ClipId, idx: usize, pid: &str) -> String { format!("graph:{}:{}:{}", clip.0, idx, pid) } -/// Value and velocity graphs of an animated scalar parameter, drawn across the keyframe lane. +/// Parameters with value/velocity graphs: scalars, and points such as Position (#239). +pub(crate) fn graphable(v: &ParamValue) -> bool { + matches!(v, ParamValue::Float(_) | ParamValue::Vec2(_)) +} + +/// The curves a graphed value draws: a scalar's one, or a point's X and Y. +fn graph_components(v: &ParamValue) -> Vec { + match v { + ParamValue::Float(x) => vec![*x], + ParamValue::Vec2(p) => vec![p.x, p.y], + _ => Vec::new(), + } +} + +/// Value and velocity graphs of an animated scalar or point parameter, drawn across the keyframe +/// lane. A point draws an X and a Y curve, and its velocity graph is the speed along its path. /// Keyframes drag vertically (value); Bezier influence handles drag horizontally. #[allow(clippy::too_many_arguments)] pub(crate) fn graph_rows( @@ -917,15 +934,14 @@ pub(crate) fn graph_rows( let to_media = |f: f64| it.source_in + Tick((f * dur * speed) as i64); let to_f = |m: Tick| ((m - it.source_in).0 as f64 / speed / dur) as f32; let x_of = |f: f32| lane.min.x + f * lane.width(); - // samples + // samples: one row of curve values per sample (a point's NaN "auto" coordinate is skipped) let n = (lane.width() / 2.0).max(8.0) as usize; - let vals: Vec = (0..=n).map(|i| param.f64_at(to_media(i as f64 / n as f64))).collect(); - let (mut lo, mut hi) = vals.iter().fold((f64::MAX, f64::MIN), |(a, b), v| (a.min(*v), b.max(*v))); - for k in ¶m.keyframes { - if let ParamValue::Float(v) = k.value { - lo = lo.min(v); - hi = hi.max(v); - } + let vals: Vec> = (0..=n).map(|i| graph_components(¶m.value_at(to_media(i as f64 / n as f64)))).collect(); + let curves = graph_components(¶m.value).len(); + let at_keys: Vec> = param.keyframes.iter().map(|k| graph_components(&k.value)).collect(); + let (mut lo, mut hi) = vals.iter().chain(&at_keys).flatten().filter(|v| v.is_finite()).fold((f64::MAX, f64::MIN), |(a, b), v| (a.min(*v), b.max(*v))); + if lo > hi { + (lo, hi) = (0.0, 0.0); } if hi - lo < 1e-6 { lo -= 1.0; @@ -951,17 +967,35 @@ pub(crate) fn graph_rows( p.text(pos2(vr.max.x - 30.0, area.min.y + 6.0), Align2::RIGHT_CENTER, format!("{hi:.dec$}"), Tokens::ui(10.0), t.text_dim); p.text(pos2(vr.max.x - 30.0, area.max.y - 6.0), Align2::RIGHT_CENTER, format!("{lo:.dec$}"), Tokens::ui(10.0), t.text_dim); p.text(pos2(vr.min.x + 44.0, area.center().y), Align2::LEFT_CENTER, tl!("Value"), Tokens::ui(11.0), t.text_dim); - let line: Vec = vals.iter().enumerate().map(|(i, v)| pos2(x_of(i as f32 / n as f32), y_of(*v))).collect(); - p.add(egui::Shape::line(line, Stroke::new(1.5, t.accent))); - // velocity (units per second, derivative of the sampled value) - let secs = dur * speed / filmcraft_time::TICKS_PER_SECOND as f64 / n as f64; - let vel: Vec = (0..n).map(|i| (vals[i + 1] - vals[i]) / secs.max(1e-9)).collect(); - let vmax = vel.iter().fold(1e-6f64, |a, v| a.max(v.abs())) * 1.15; + let curve_color = |c: usize| if c == 0 { t.accent } else { Y_CURVE }; + if curves > 1 { + p.text(pos2(vr.min.x + 44.0, area.center().y + 14.0), Align2::LEFT_CENTER, "X", Tokens::ui(10.5), curve_color(0)); + p.text(pos2(vr.min.x + 58.0, area.center().y + 14.0), Align2::LEFT_CENTER, "Y", Tokens::ui(10.5), curve_color(1)); + } + for c in 0..curves { + let line: Vec = + vals.iter().enumerate().filter_map(|(i, v)| v.get(c).filter(|v| v.is_finite()).map(|v| pos2(x_of(i as f32 / n as f32), y_of(*v)))).collect(); + p.add(egui::Shape::line(line, Stroke::new(1.5, curve_color(c)))); + } + // velocity (units per second, derivative of the sampled value; a point's speed along its path) + let secs = (dur * speed / filmcraft_time::TICKS_PER_SECOND as f64 / n as f64).max(1e-9); + let vel: Vec = vals + .windows(2) + .map(|w| { + let [a, b] = w else { return 0.0 }; + let d: Vec = a.iter().zip(b).map(|(a, b)| (b - a) / secs).collect(); + match d.as_slice() { + [v] => *v, + d => d.iter().map(|v| v * v).sum::().sqrt(), + } + }) + .collect(); + let vmax = vel.iter().filter(|v| v.is_finite()).fold(1e-6f64, |a, v| a.max(v.abs())) * 1.15; let varea = Rect::from_min_max(pos2(lane.min.x, velr.min.y + 2.0), pos2(lane.max.x, velr.max.y - 4.0)); p.rect_filled(varea, 0.0, t.keyframe_plot_bg); let vy = |v: f64| varea.center().y - (v / vmax) as f32 * varea.height() / 2.0; p.line_segment([pos2(varea.min.x, varea.center().y), pos2(varea.max.x, varea.center().y)], Stroke::new(1.0, t.keyframe_plot_axis)); - let vline: Vec = vel.iter().enumerate().map(|(i, v)| pos2(x_of((i as f32 + 0.5) / n as f32), vy(*v))).collect(); + let vline: Vec = vel.iter().enumerate().filter(|(_, v)| v.is_finite()).map(|(i, v)| pos2(x_of((i as f32 + 0.5) / n as f32), vy(*v))).collect(); p.add(egui::Shape::line(vline, Stroke::new(1.2, Color32::from_rgb(0xd0, 0xa0, 0x40)))); p.text(pos2(velr.min.x + 44.0, varea.center().y), Align2::LEFT_CENTER, tl!("Velocity"), Tokens::ui(11.0), t.text_dim); p.text(pos2(velr.max.x - 30.0, varea.min.y + 6.0), Align2::RIGHT_CENTER, format!("{vmax:.1}/s"), Tokens::ui(10.0), t.text_dim); @@ -969,55 +1003,83 @@ pub(crate) fn graph_rows( // keyframes + handles let ks = ¶m.keyframes; for (i, k) in ks.iter().enumerate() { - let ParamValue::Float(v) = k.value else { continue }; let f = to_f(k.time); if !(-0.01..=1.01).contains(&f) { continue; } - let id = egui::Id::new(("kfg", clip.0, idx, mask, pd.id, k.time.0)); - let dy: f32 = ui.data(|d| d.get_temp(id)).unwrap_or(0.0); - let c = pos2(x_of(f), y_of(v) + dy); - let r = Rect::from_center_size(c, vec2(10.0, 10.0)); - let resp = ui.interact(r.expand(2.0), id, Sense::drag()); - app.auto.add(&format!("effectControls.{}.graph.keyframe.{}", pd.id, k.time.0), r, "keyframe value"); - // influence handles: flat (ease) tangents with length ∝ influence × neighbouring segment let eases_out = !matches!(k.interp, filmcraft_project::Interpolation::Linear | filmcraft_project::Interpolation::Hold | filmcraft_project::Interpolation::EaseIn); let eases_in = !matches!(k.interp, filmcraft_project::Interpolation::Linear | filmcraft_project::Interpolation::Hold | filmcraft_project::Interpolation::EaseOut); - for (side, on, nb) in [(1.0f32, eases_out, ks.get(i + 1)), (-1.0f32, eases_in && i > 0, if i > 0 { ks.get(i - 1) } else { None })] { - let (true, Some(nb)) = (on, nb) else { continue }; - let seg = (x_of(to_f(nb.time)) - c.x).abs(); - let infl = if side > 0.0 { k.out_influence } else { k.in_influence } as f32; - let hid = id.with(if side > 0.0 { "out" } else { "in" }); - let hdx: f32 = ui.data(|d| d.get_temp(hid)).unwrap_or(0.0); - let hx = c.x + side * (infl * seg + hdx * side).clamp(seg * 0.01, seg); - let hp = pos2(hx, c.y); - p.line_segment([c, hp], Stroke::new(1.0, t.plot_handle_dim)); - p.circle_filled(hp, 3.5, t.keyframe_handle); - let hr = ui.interact(Rect::from_center_size(hp, vec2(10.0, 10.0)), hid.with("h"), Sense::drag()); - if hr.dragged() { - let nx = graph_drag_offset(ui, &hr, vec2(hdx, 0.0)).x; - ui.data_mut(|d| d.insert_temp(hid, nx)); + let comps = graph_components(&k.value); + for (ci, &v) in comps.iter().enumerate() { + if !v.is_finite() { + continue; } - if hr.drag_stopped() { - ui.data_mut(|d| d.remove::(hid)); - let ni = ((infl * seg + hdx * side) / seg.max(1.0)).clamp(0.01, 1.0); - let key = if side > 0.0 { "outInfluence" } else { "inInfluence" }; - actions.push(("effects.setKeyframe".into(), with_mask(json!({"clip": clip.0, "effect": idx, "param": pd.id, "mediaTime": k.time.0, key: ni})))); + let id = egui::Id::new(("kfg", clip.0, idx, mask, pd.id, k.time.0)); + let id = if ci == 0 { id } else { id.with(ci) }; + let dy: f32 = ui.data(|d| d.get_temp(id)).unwrap_or(0.0); + let c = pos2(x_of(f), y_of(v) + dy); + let r = Rect::from_center_size(c, vec2(10.0, 10.0)); + let resp = ui.interact(r.expand(2.0), id, Sense::drag()); + let auto_id = match (comps.len(), ci) { + (1, _) => format!("effectControls.{}.graph.keyframe.{}", pd.id, k.time.0), + (_, 0) => format!("effectControls.{}.graph.keyframe.{}.x", pd.id, k.time.0), + _ => format!("effectControls.{}.graph.keyframe.{}.y", pd.id, k.time.0), + }; + app.auto.add(&auto_id, r, "keyframe value"); + // influence handles: flat (ease) tangents with length ∝ influence × neighbouring segment; + // a point's sit on its X curve only, as both coordinates share the influence + let (eases_out, eases_in) = (ci == 0 && eases_out, ci == 0 && eases_in); + for (side, on, nb) in [(1.0f32, eases_out, ks.get(i + 1)), (-1.0f32, eases_in && i > 0, if i > 0 { ks.get(i - 1) } else { None })] { + let (true, Some(nb)) = (on, nb) else { continue }; + let seg = (x_of(to_f(nb.time)) - c.x).abs(); + let infl = if side > 0.0 { k.out_influence } else { k.in_influence } as f32; + let hid = id.with(if side > 0.0 { "out" } else { "in" }); + let hdx: f32 = ui.data(|d| d.get_temp(hid)).unwrap_or(0.0); + let hx = c.x + side * (infl * seg + hdx * side).clamp(seg * 0.01, seg); + let hp = pos2(hx, c.y); + p.line_segment([c, hp], Stroke::new(1.0, t.plot_handle_dim)); + p.circle_filled(hp, 3.5, t.keyframe_handle); + let hr = ui.interact(Rect::from_center_size(hp, vec2(10.0, 10.0)), hid.with("h"), Sense::drag()); + if hr.dragged() { + let nx = graph_drag_offset(ui, &hr, vec2(hdx, 0.0)).x; + ui.data_mut(|d| d.insert_temp(hid, nx)); + } + if hr.drag_stopped() { + ui.data_mut(|d| d.remove::(hid)); + let ni = ((infl * seg + hdx * side) / seg.max(1.0)).clamp(0.01, 1.0); + let key = if side > 0.0 { "outInfluence" } else { "inInfluence" }; + actions.push(( + "effects.setKeyframe".into(), + with_mask(json!({"clip": clip.0, "effect": idx, "param": pd.id, "mediaTime": k.time.0, key: ni})), + )); + } + } + p.circle_filled(c, 4.5, if resp.dragged() { t.hot_text } else { t.plot_handle }); + if resp.dragged() { + let ny = graph_drag_offset(ui, &resp, vec2(0.0, dy)).y; + ui.data_mut(|d| d.insert_temp(id, ny)); + p.text(c + vec2(8.0, -10.0), Align2::LEFT_BOTTOM, format!("{:.dec$}", v_of(c.y)), Tokens::ui(10.5), t.hot_text); + } + if resp.drag_stopped() { + ui.data_mut(|d| d.remove::(id)); + let nv = v_of(c.y); + let nv = if let ParamKind::Float { min, max, .. } = pd.kind { nv.clamp(min, max) } else { nv }; + let value = if comps.len() == 1 { + json!(nv) + } else { + let mut moved = comps.clone(); + if let Some(slot) = moved.get_mut(ci) { + *slot = nv; + } + json!(moved) + }; + actions.push(( + "effects.setKeyframe".into(), + with_mask(json!({"clip": clip.0, "effect": idx, "param": pd.id, "mediaTime": k.time.0, "value": value})), + )); } - } - p.circle_filled(c, 4.5, if resp.dragged() { t.hot_text } else { t.plot_handle }); - if resp.dragged() { - let ny = graph_drag_offset(ui, &resp, vec2(0.0, dy)).y; - ui.data_mut(|d| d.insert_temp(id, ny)); - p.text(c + vec2(8.0, -10.0), Align2::LEFT_BOTTOM, format!("{:.dec$}", v_of(c.y)), Tokens::ui(10.5), t.hot_text); - } - if resp.drag_stopped() { - ui.data_mut(|d| d.remove::(id)); - let nv = v_of(c.y); - let nv = if let ParamKind::Float { min, max, .. } = pd.kind { nv.clamp(min, max) } else { nv }; - actions.push(("effects.setKeyframe".into(), with_mask(json!({"clip": clip.0, "effect": idx, "param": pd.id, "mediaTime": k.time.0, "value": nv})))); } } } @@ -1201,33 +1263,33 @@ mod graph_drag_tests { ctx: egui::Context, clip: ClipId, time: f64, + param: &'static str, } impl Driver { fn new() -> Self { + Self::with("scale", ParamValue::Float(40.0), ParamValue::Float(80.0)) + } + + fn with(param: &'static str, from: ParamValue, to: ParamValue) -> Self { let mut session = filmcraft_engine::Session::default(); session.execute("file.openDemoProject", json!({})).unwrap(); let seq = session.state.active_sequence.unwrap(); let clip = session.active_sequence().unwrap().video_tracks[0].items[0].id; let it = std::sync::Arc::make_mut(&mut session.project).sequence_mut(seq).unwrap().find_item_mut(clip).unwrap().1; - let mut a = Keyframe::new(it.source_in + Tick(it.duration.0 / 4), ParamValue::Float(40.0)); - let mut b = Keyframe::new(it.source_in + Tick(it.duration.0 * 3 / 4), ParamValue::Float(80.0)); + let mut a = Keyframe::new(it.source_in + Tick(it.duration.0 / 4), from.clone()); + let mut b = Keyframe::new(it.source_in + Tick(it.duration.0 * 3 / 4), to); a.interp = Interpolation::Bezier; b.interp = Interpolation::Bezier; - it.effects - .iter_mut() - .find(|e| e.effect == "motion") - .unwrap() - .params - .insert("scale".into(), Param { value: ParamValue::Float(40.0), keyframes: vec![a, b] }); + it.effects.iter_mut().find(|e| e.effect == "motion").unwrap().params.insert(param.into(), Param { value: from, keyframes: vec![a, b] }); let app = FilmcraftApp::new(session); let ctx = egui::Context::default(); crate::theme::install(&ctx, &app.tokens); - Self { app, ctx, clip, time: 0.0 } + Self { app, ctx, clip, time: 0.0, param } } fn param(&self) -> Param { - self.app.session.active_sequence().unwrap().find_item(self.clip).unwrap().1.effect("motion").unwrap().params["scale"].clone() + self.app.session.active_sequence().unwrap().find_item(self.clip).unwrap().1.effect("motion").unwrap().params[self.param].clone() } fn undo_len(&mut self) -> usize { @@ -1243,11 +1305,11 @@ mod graph_drag_tests { let it = self.app.session.active_sequence().unwrap().find_item(self.clip).unwrap().1.clone(); let index = it.effects.iter().position(|e| e.effect == "motion").unwrap(); let effect = &it.effects[index]; - let pd = effect.def().unwrap().param("scale").unwrap(); + let pd = effect.def().unwrap().param(self.param).unwrap(); let body = Rect::from_min_size(pos2(8.0, 8.0), vec2(320.0, 200.0)); let lane = Rect::from_min_max(pos2(360.0, 8.0), pos2(760.0, 220.0)); let mut actions = Vec::new(); - graph_rows(&mut self.app, ui, body, self.clip, index, None, pd, &effect.params["scale"], &lane, &it, &mut actions); + graph_rows(&mut self.app, ui, body, self.clip, index, None, pd, &effect.params[self.param], &lane, &it, &mut actions); run(&mut self.app, ui.ctx(), actions); }); out.textures_delta.clear(); @@ -1320,4 +1382,26 @@ mod graph_drag_tests { } } } + + /// #239: Position (a point) has graphs too; dragging its X keyframe moves only X, in one undo step. + #[test] + fn position_graph_drags_one_coordinate_of_a_point_keyframe() { + use filmcraft_geom::Vec2; + let mut d = Driver::with("position", ParamValue::Vec2(Vec2::new(400.0, 300.0)), ParamValue::Vec2(Vec2::new(800.0, 500.0))); + assert!(graphable(&d.param().value)); + let before = d.param(); + let undo = d.undo_len(); + let start = d.frame(vec![])[0]; + assert!(d.app.auto.find(&format!("effectControls.position.graph.keyframe.{}.y", before.keyframes[0].time.0)).is_some()); + d.frame(vec![egui::Event::PointerMoved(start), button(start, true)]); + d.frame(vec![egui::Event::PointerMoved(start - vec2(0.0, 20.0))]); + d.frame(vec![button(start - vec2(0.0, 20.0), false)]); + d.frame(vec![]); + let changed = d.param(); + assert_eq!(d.undo_len(), undo + 1); + let (old, new) = (before.keyframes[0].value.as_vec2().unwrap(), changed.keyframes[0].value.as_vec2().unwrap()); + assert!(new.x > old.x, "dragging the X keyframe up raises X: {old:?} -> {new:?}"); + assert_eq!(new.y, old.y); + assert_eq!(changed.keyframes[1], before.keyframes[1]); + } } From 316435e54d2636b9f2ba8184eb4808cc28df1684 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:36:50 +0700 Subject: [PATCH 051/144] fix(monitor): make Button Editor customize transport controls (#431) --- crates/ui-egui/src/panels/monitor.rs | 65 ++++++++++++++++++++++++++-- crates/ui-egui/src/state.rs | 4 ++ 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels/monitor.rs b/crates/ui-egui/src/panels/monitor.rs index 11f4bc2d..ddd15708 100644 --- a/crates/ui-egui/src/panels/monitor.rs +++ b/crates/ui-egui/src/panels/monitor.rs @@ -692,6 +692,19 @@ fn mini_timeline( } } +// A hidden transport action is still available through its regular shortcut/menu. +fn transport_visible(hidden: &[String], command: &str) -> bool { + !hidden.iter().any(|id| id == command) +} + +fn set_transport_visible(hidden: &mut Vec, command: &str, visible: bool) { + if visible { + hidden.retain(|id| id != command); + } else if transport_visible(hidden, command) { + hidden.push(command.to_owned()); + } +} + fn transport(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect, which: Which) { let t = app.tokens; let src = which == Which::Source; @@ -726,12 +739,21 @@ fn transport(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect, which: Which) (Icon::Proxy, "media.toggleProxies", tl!("Toggle Proxies")), ] }; + let mut buttons = buttons; + if !src { + // Optional control, discoverable through the editor without changing existing layouts. + buttons.push((Icon::Loop, "playback.loop", tl!("Loop"))); + } + let hidden = monitor_view::view(app, which).transport_hidden.clone(); let bw = 30.0; - let total = buttons.len() as f32 * bw; + let total = buttons.iter().filter(|(_, cmd, _)| transport_visible(&hidden, cmd)).count() as f32 * bw; let mut x = row.center().x - total / 2.0; let ctx = ui.ctx().clone(); let prefix = if src { "source" } else { "program" }; - for (icon, cmd, tip) in buttons { + for &(icon, cmd, tip) in &buttons { + if !transport_visible(&hidden, cmd) { + continue; + } let r = Rect::from_min_size(pos2(x, row.min.y + 2.0), vec2(bw - 2.0, 26.0)); let resp = ui.interact(r, egui::Id::new((prefix, cmd)), Sense::click()).on_hover_text(tip); app.auto.add(&format!("{prefix}.transport.{cmd}"), r, tip); @@ -767,10 +789,32 @@ fn transport(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect, which: Which) } x += bw; } - // button editor "+" + // Customize this monitor's transport separately from the other monitor. let r = Rect::from_min_size(pos2(row.max.x - 30.0, row.min.y + 4.0), vec2(22.0, 22.0)); let resp = ui.interact(r, egui::Id::new((prefix, "btn-editor")), Sense::click()).on_hover_text(tl!("Button Editor")); + app.auto.add(&format!("{prefix}.transport.editor"), r, "Button Editor"); icons::paint(ui.painter(), r.shrink(5.0), Icon::Plus, if resp.hovered() { t.tab_text_active } else { t.text_dim }); + let mut edited_hidden = hidden.clone(); + egui::Popup::menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + ui.set_min_width(225.0); + for &(_, cmd, tip) in &buttons { + let mut visible = transport_visible(&edited_hidden, cmd); + let checkbox = ui.checkbox(&mut visible, tip); + app.auto.add(&format!("{prefix}.transport.editor.{cmd}"), checkbox.rect, tip); + if checkbox.changed() { + set_transport_visible(&mut edited_hidden, cmd, visible); + } + } + ui.separator(); + let reset = ui.button(tl!("Reset")); + app.auto.add(&format!("{prefix}.transport.editor.reset"), reset.rect, "Reset"); + if reset.clicked() { + edited_hidden = crate::state::MonitorView::default().transport_hidden; + } + }); + if edited_hidden != hidden { + monitor_view::view_mut(app, which).transport_hidden = edited_hidden; + } } fn source_nav(app: &mut FilmcraftApp, cmd: &str) { @@ -797,6 +841,21 @@ mod tests { Rgba { w, h, px: rgba.iter().copied().cycle().take(w * h * 4).collect() } } + #[test] + fn button_editor_updates_visibility_without_affecting_other_monitor() { + let mut program = crate::state::MonitorView::default().transport_hidden; + let source = program.clone(); + assert!(!transport_visible(&program, "playback.loop")); + set_transport_visible(&mut program, "playback.loop", true); + set_transport_visible(&mut program, "playback.loop", true); + assert!(transport_visible(&program, "playback.loop")); + assert!(program.is_empty(), "enabling twice must not leave hidden entries"); + set_transport_visible(&mut program, "playhead.stepForward", false); + set_transport_visible(&mut program, "playhead.stepForward", false); + assert_eq!(program, vec!["playhead.stepForward"]); + assert!(transport_visible(&source, "playhead.stepForward")); + } + #[test] fn eyedropper_averages_the_pixels_around_the_point() { let mut img = flat(8, 8, [0, 200, 0, 255]); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index aacea877..4425775c 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -277,6 +277,8 @@ pub struct MonitorView { pub pan: [f32; 2], pub safe_margins: bool, pub show_transport: bool, + /// Transport buttons hidden through the monitor's Button Editor. Stored per monitor. + pub transport_hidden: Vec, /// Program monitor display mode Multi-Camera (angle grid + program). pub multicam: bool, pub display: DisplayMode, @@ -300,6 +302,8 @@ impl Default for MonitorView { pan: [0.0, 0.0], safe_margins: false, show_transport: true, + // The optional Loop button is available from the editor without altering default layouts. + transport_hidden: vec!["playback.loop".into()], multicam: false, display: DisplayMode::Composite, compare_ref: None, From 2fe4715d5cc2f965e565fd808e89cd39aa2d8b8d Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:41:13 +0700 Subject: [PATCH 052/144] feat(timeline): rename tracks from their header (#654) --- crates/ui-egui/src/panels/timeline.rs | 32 ++++++++++++++++ crates/ui-egui/tests/timeline_rename_ui.rs | 44 ++++++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 crates/ui-egui/tests/timeline_rename_ui.rs diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index d5997839..5304eaec 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1109,6 +1109,38 @@ fn draw_headers(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, rows: egui::Id::new(("hdr", r.track.0)), Sense::click(), ); + app.auto.add(&format!("timeline.track.{label}.name"), resp.rect, "Track name (right-click to rename)"); + let rename_id = egui::Id::new(("timeline-track-rename", r.track.0)); + if resp.secondary_clicked() { + // Start each menu opening from the committed name, not a cancelled draft. + ui.data_mut(|d| d.insert_temp(rename_id, tr.name.clone())); + } + egui::Popup::context_menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + ui.set_min_width(220.0); + ui.label(tl!("Rename")); + let mut name: String = ui.data(|d| d.get_temp(rename_id)).unwrap_or_else(|| tr.name.clone()); + let input = ui.add(egui::TextEdit::singleline(&mut name).desired_width(210.0)); + app.auto.add(&format!("timeline.track.{label}.renameField"), input.rect, "New track name"); + if resp.secondary_clicked() { + input.request_focus(); + } + let save_on_enter = input.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + let valid = !name.trim().is_empty(); + ui.horizontal(|ui| { + let apply = ui.add_enabled(valid, egui::Button::new(tl!("Apply"))); + app.auto.add(&format!("timeline.track.{label}.renameApply"), apply.rect, "Apply track name"); + if (apply.clicked() || (valid && save_on_enter)) && name.trim() != tr.name { + actions.push(("timeline.setTrack".into(), json!({"track": r.track.0, "name": name.trim()}))); + ui.close(); + } else if apply.clicked() || (valid && save_on_enter) { + ui.close(); + } + if ui.button(tl!("Cancel")).clicked() { + ui.close(); + } + }); + ui.data_mut(|d| d.insert_temp(rename_id, name)); + }); if resp.double_clicked() { let h = if r.kind == TrackKind::Video { &mut app.ui.timeline.video_track_h } else { &mut app.ui.timeline.audio_track_h }; *h = if *h < 50.0 { 64.0 } else { 30.0 }; diff --git a/crates/ui-egui/tests/timeline_rename_ui.rs b/crates/ui-egui/tests/timeline_rename_ui.rs new file mode 100644 index 00000000..697fc81f --- /dev/null +++ b/crates/ui-egui/tests/timeline_rename_ui.rs @@ -0,0 +1,44 @@ +//! Right-click renaming a Timeline track uses timeline.setTrack, which is undoable. + +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_ui_egui::FilmcraftApp; +use serde_json::json; + +fn step(h: &mut Harness<'static, FilmcraftApp>) { + let ctx = h.ctx.clone(); + let mut raw = std::mem::take(h.input_mut()); + eframe::App::raw_input_hook(h.state_mut(), &ctx, &mut raw); + *h.input_mut() = raw; + ctx.request_repaint(); + h.step(); +} + +fn click(h: &mut Harness<'static, FilmcraftApp>, id: &str, button: egui::PointerButton) { + let [x, y, w, height] = h.state().auto.find(id).expect("visible control").rect; + let at = egui::pos2(x + w * 0.5, y + height * 0.5); + h.input_mut().events.push(egui::Event::PointerMoved(at)); + step(h); + h.input_mut().events.push(egui::Event::PointerButton { pos: at, button, pressed: true, modifiers: Default::default() }); + step(h); + h.input_mut().events.push(egui::Event::PointerButton { pos: at, button, pressed: false, modifiers: Default::default() }); + step(h); +} + +#[test] +fn rename_from_video_track_header_is_undoable() { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).expect("demo project"); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_cc| FilmcraftApp::new(s)); + for _ in 0..5 { step(&mut h); } + let original = h.state().session.active_sequence().expect("active sequence").video_tracks[0].name.clone(); + let id = h.state().session.active_sequence().expect("active sequence").video_tracks[0].id.0; + click(&mut h, "timeline.track.V1.name", egui::PointerButton::Secondary); + assert!(h.state().auto.find("timeline.track.V1.renameField").is_some(), "track rename popup should open"); + h.ctx.data_mut(|d| d.insert_temp(egui::Id::new(("timeline-track-rename", id)), "B-roll".to_string())); + step(&mut h); + click(&mut h, "timeline.track.V1.renameApply", egui::PointerButton::Primary); + assert_eq!(h.state().session.active_sequence().expect("active sequence").video_tracks[0].name, "B-roll"); + h.state_mut().session.execute("edit.undo", json!({})).expect("undo rename"); + assert_eq!(h.state().session.active_sequence().expect("active sequence").video_tracks[0].name, original); +} From 5b9a909d477f9d55ed9ced1f96da30ee36ce1959 Mon Sep 17 00:00:00 2001 From: Manuel Camacho Date: Sat, 10 Oct 2026 09:41:45 -0700 Subject: [PATCH 053/144] HW5: AV1 sequence headers compare by picture format, so SVT-AV1 files stay on hardware (#30) FrameStreamInfo::parameters_changed compared the in-band AV1 sequence header with av1C's byte for byte. SVT-AV1 writes a provisional av1C header whose coding tool flags (CDEF, restoration, warped motion...) differ from the in-band one, so every such MP4 dropped to the software decoder from its first frame, on every hardware backend. Headers now compare by what the picture format depends on: profile, maximum size and colour configuration. A header that does not parse still counts as a change. Regression test with an SVT-AV1 720p 10-bit av1C / in-band pair. Co-Authored-By: Claude Opus 5.5 --- crates/codecs/src/hw_frame.rs | 57 +++++++++++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 3 deletions(-) diff --git a/crates/codecs/src/hw_frame.rs b/crates/codecs/src/hw_frame.rs index b37fc4d0..dfa64771 100644 --- a/crates/codecs/src/hw_frame.rs +++ b/crates/codecs/src/hw_frame.rs @@ -82,6 +82,13 @@ pub(crate) fn obus(data: &[u8]) -> Vec<(u8, &[u8])> { out } +/// What an AV1 sequence header fixes about the pictures: profile, maximum size and the colour +/// configuration (bit depth, chroma format, colour description). `None` when it does not parse. +fn av1_format(seq: &[u8]) -> Option<(u8, u32, u32, filmcraft_av1::ColorConfig)> { + let h = filmcraft_av1::SequenceHeader::parse(seq).ok()?; + Some((h.profile, h.max_frame_width, h.max_frame_height, h.color)) +} + /// VP9 colour as our decoder reports it: `color_space` and range from the bitstream, transfer and /// primaries from the container when it says. (Shared with `Vp9Decoder`.) pub(crate) fn vp9_color(width: u32, height: u32, color_space: u8, full_range: bool, transfer: Option, primaries: Option) -> ColorInfo { @@ -243,14 +250,58 @@ impl FrameStreamInfo { } /// Whether `sample` declares parameters the hardware session was not set up for: an AV1 - /// sequence header that differs from `av1C`'s, a VP9 key frame of another size, bit depth or - /// chroma format. The decoder then hands the stream to our software decoder. + /// sequence header whose picture format (profile, size, colour configuration) differs from + /// `av1C`'s, a VP9 key frame of another size, bit depth or chroma format. The decoder then + /// hands the stream to our software decoder. pub fn parameters_changed(&self, sample: &[u8]) -> bool { match self.codec { - FrameCodec::Av1 => obus(sample).into_iter().any(|(t, p)| t == 1 && self.sequence_header.as_deref() != Some(p)), + // Only what the picture format depends on counts: encoders such as SVT-AV1 put a + // provisional sequence header in `av1C` whose coding tool flags (CDEF, restoration, + // warped motion…) differ from the in-band one, and decoders follow the in-band one. + FrameCodec::Av1 => obus(sample).into_iter().any(|(t, p)| t == 1 && self.sequence_header.as_deref().is_none_or(|s| av1_format(s) != av1_format(p))), FrameCodec::Vp9 => { self.picture_params(sample).is_some_and(|p| p.size != self.size || p.bit_depth != self.bit_depth || p.subsampling != self.subsampling) } } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// An `av1C` sequence header from SVT-AV1 (1280x720 10-bit) and the in-band one of the same + /// stream: they differ only in coding tool flags. + const AV1C: [u8; 14] = [0x0a, 0x0c, 0x02, 0x00, 0x00, 0x2d, 0x6a, 0x67, 0xfd, 0x9e, 0x01, 0x7c, 0x20, 0x20]; + const IN_BAND: [u8; 14] = [0x0a, 0x0c, 0x02, 0x00, 0x00, 0x2d, 0x6a, 0x67, 0xfd, 0x9e, 0x35, 0x7c, 0xe0, 0x20]; + + fn info() -> FrameStreamInfo { + let c = filmcraft_isobmff::Av1Config { + seq_profile: 0, + seq_level_idx_0: 0, + seq_tier_0: false, + high_bitdepth: true, + twelve_bit: false, + monochrome: false, + chroma_subsampling_x: true, + chroma_subsampling_y: true, + chroma_sample_position: 0, + initial_presentation_delay_minus_one: None, + config_obus: AV1C.to_vec(), + }; + FrameStreamInfo::from_av1(&c, (0, 0)).unwrap() + } + + #[test] + fn av1_sequence_headers_compare_by_picture_format() { + let i = info(); + assert_eq!((i.size, i.bit_depth), ((1280, 720), 10)); + assert!(!i.parameters_changed(&AV1C), "the same header"); + // used to send every SVT-AV1 MP4 to the software decoder from its first frame + assert!(!i.parameters_changed(&IN_BAND), "only coding tool flags differ"); + // a header that does not parse is a change + assert!(i.parameters_changed(&[0x0a, 0x02, 0xe0, 0x00])); + // no sequence header in the sample: nothing changed + assert!(!i.parameters_changed(&[0x12, 0x00])); + } +} From ff171b9c8cc9ab42b6f9522f3680f0d97f7108c6 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 03:41:52 +1100 Subject: [PATCH 054/144] #680: Preserve individual linked-clip selections when deleting media. --- crates/engine/src/commands.rs | 8 +++--- crates/engine/src/tests.rs | 39 +++++++++++++++++++++++++++ crates/ui-egui/src/panels/timeline.rs | 11 +++----- crates/ui-egui/tests/keyboard_ui.rs | 25 +++++++++++++++++ 4 files changed, 71 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 0b3a9f8a..52ad73c4 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1221,7 +1221,7 @@ fn build() -> Vec { let caps = s.state.caption_selection.clone(); return crate::captions::delete(s, &caps, false); } - let sel = with_links(s, &clips_p(s, p)); + let sel = if p.get("clips").is_some() || p.get("clip").is_some() { with_links(s, &clips_p(s, p)) } else { s.state.selection.clone() }; s.edit_sequence("Clear", |q, _, st| { edit::delete_items(q, &sel); st.selection.clear(); @@ -1234,7 +1234,7 @@ fn build() -> Vec { let caps = s.state.caption_selection.clone(); return crate::captions::delete(s, &caps, true); } - let sel = with_links(s, &clips_p(s, p)); + let sel = if p.get("clips").is_some() || p.get("clip").is_some() { with_links(s, &clips_p(s, p)) } else { s.state.selection.clone() }; s.edit_sequence("Ripple Delete", |q, _, st| { let spans = edit::ripple_delete_items(q, &sel)?; if st.ripple_sequence_markers { @@ -2136,13 +2136,13 @@ fn build() -> Vec { Ok(json!({"clips": ids.iter().map(|c| c.0).collect::>()})) } ), - cmd!("timeline.select", "Select Clips", [], None, r#"{"clips":[id],"transitions":[id]?,"add":bool,"toggle":bool}"#, has_seq, |s, p| { + cmd!("timeline.select", "Select Clips", [], None, r#"{"clips":[id],"transitions":[id]?,"add":bool,"toggle":bool,"linked":bool?}"#, has_seq, |s, p| { if let Some(ids) = p.get("transitions") { return select_transitions(s, ids, bool_p(p, "add").unwrap_or(false), bool_p(p, "toggle").unwrap_or(false)); } let clips: Vec = p.get("clips").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_u64().map(ClipId)).collect()).unwrap_or_default(); - let clips = with_links(s, &clips); + let clips = if bool_p(p, "linked").unwrap_or(true) { with_links(s, &clips) } else { clips }; // selecting clips leaves trim mode (Premiere: clip and edit point selections are exclusive) s.state.edit_points.clear(); s.state.trim_shift = Default::default(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9852734d..f2b50dd8 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -139,6 +139,45 @@ fn trim_linked_and_ripple_delete() { assert_eq!(q.video_tracks[0].items[0].start, s.sequence_rate().tick_of(12), "the trim gap remains"); } +#[test] +fn clear_respects_individually_selected_linked_clips_and_undo() { + for audio in [false, true] { + let mut s = demo(); + let video = s.active_sequence().unwrap().video_tracks[0].items[0].clone(); + let sound = s.active_sequence().unwrap().audio_tracks[0].items.iter().find(|i| i.link == video.link).unwrap().clone(); + let (selected, partner) = if audio { (sound.id, video.id) } else { (video.id, sound.id) }; + s.execute("timeline.select", json!({"clips": [selected.0], "linked": false})).unwrap(); + assert_eq!(s.state.selection, vec![selected]); + s.execute("edit.clear", json!({})).unwrap(); + assert!(s.active_sequence().unwrap().find_item(selected).is_none()); + assert!(s.active_sequence().unwrap().find_item(partner).is_some()); + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.active_sequence().unwrap().find_item(selected).is_some()); + s.execute("timeline.select", json!({"clips": [selected.0]})).unwrap(); + assert_eq!(s.state.selection.len(), 2); + s.execute("edit.clear", json!({})).unwrap(); + assert!(s.active_sequence().unwrap().find_item(partner).is_none()); + } +} + +#[test] +fn ripple_delete_respects_individual_selection_and_explicit_clear_keeps_link_behavior() { + let mut s = demo(); + let video = s.active_sequence().unwrap().video_tracks[0].items[0].clone(); + let sound = s.active_sequence().unwrap().audio_tracks[0].items.iter().find(|i| i.link == video.link).unwrap().id; + let tracks: Vec<_> = s.active_sequence().unwrap().all_tracks().map(|t| t.id.0).collect(); + for track in tracks { + s.execute("timeline.setTrack", json!({"track": track, "syncLock": false})).unwrap(); + } + s.execute("timeline.select", json!({"clips": [video.id.0], "linked": false})).unwrap(); + s.execute("edit.rippleDelete", json!({})).unwrap(); + assert!(s.active_sequence().unwrap().find_item(video.id).is_none()); + assert!(s.active_sequence().unwrap().find_item(sound).is_some()); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.clear", json!({"clips": [video.id.0]})).unwrap(); + assert!(s.active_sequence().unwrap().find_item(sound).is_none()); +} + #[test] fn slide_moves_linked_audio_with_the_video() { let mut s = demo(); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index d5997839..474214f7 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1963,14 +1963,9 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & // select (shift toggles; alt selects one side of a link) let sel = &app.session.state.selection; if mods.shift { - let _ = app.session.execute("timeline.select", json!({"clips": [clip.0], "toggle": true})); - } else if !sel.contains(&clip) { - if mods.alt { - app.session.state.selection = vec![clip]; - app.session.state.transition_selection.clear(); - } else { - let _ = app.session.execute("timeline.select", json!({"clips": [clip.0]})); - } + let _ = app.session.execute("timeline.select", json!({"clips": [clip.0], "toggle": true, "linked": !mods.alt})); + } else if mods.alt || !sel.contains(&clip) { + let _ = app.session.execute("timeline.select", json!({"clips": [clip.0], "linked": !mods.alt})); } if resp.drag_started() { let clips = app.session.state.selection.clone(); diff --git a/crates/ui-egui/tests/keyboard_ui.rs b/crates/ui-egui/tests/keyboard_ui.rs index 03660632..c5db5b8d 100644 --- a/crates/ui-egui/tests/keyboard_ui.rs +++ b/crates/ui-egui/tests/keyboard_ui.rs @@ -71,6 +71,31 @@ impl Driver { } } +#[test] +fn alt_click_isolates_already_selected_linked_clip_before_delete() { + for audio in [false, true] { + let mut d = Driver::new(); + let video = d.app().session.active_sequence().unwrap().video_tracks[0].items[0].clone(); + let sound = d.app().session.active_sequence().unwrap().audio_tracks[0].items.iter().find(|i| i.link == video.link).unwrap().clone(); + let (selected, partner) = if audio { (sound.id, video.id) } else { (video.id, sound.id) }; + d.exec("timeline.select", json!({"clips": [selected.0]})); + assert_eq!(d.app().session.state.selection.len(), 2); + let r = d.app().auto.find(&format!("timeline.clip.{}", selected.0)).unwrap().rect; + d.harness.input_mut().events.push(egui::Event::ModifiersChanged(egui::Modifiers::ALT)); + d.frames(1); + d.ok("ui.click", json!({"x": r[0] + r[2] * 0.5, "y": r[1] + r[3] * 0.3, "modifiers": {"alt": true}})); + d.frames(3); + assert_eq!(d.app().session.state.selection, vec![selected]); + d.harness.input_mut().events.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + d.frames(1); + d.key("Delete"); + assert!(d.app().session.active_sequence().unwrap().find_item(selected).is_none()); + assert!(d.app().session.active_sequence().unwrap().find_item(partner).is_some()); + d.exec("edit.undo", json!({})); + assert!(d.app().session.active_sequence().unwrap().find_item(selected).is_some()); + } +} + /// 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(); From 97243984d9addbf5e8e6e94921e7a1524f3c31e9 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 03:41:59 +1100 Subject: [PATCH 055/144] #496: Keep Link Media actions accessible for large lists and long paths. --- crates/ui-egui/src/panels/media_dialogs.rs | 199 +++++++++++---------- crates/ui-egui/tests/media_ui.rs | 37 ++++ 2 files changed, 139 insertions(+), 97 deletions(-) diff --git a/crates/ui-egui/src/panels/media_dialogs.rs b/crates/ui-egui/src/panels/media_dialogs.rs index de3370d2..cef30cd8 100644 --- a/crates/ui-egui/src/panels/media_dialogs.rs +++ b/crates/ui-egui/src/panels/media_dialogs.rs @@ -162,120 +162,125 @@ fn link_media(app: &mut FilmcraftApp, ctx: &egui::Context) { let sel_path = d.candidate.and_then(|c| d.candidates.get(c)).map(|c| c.0.clone()); let preview = sel_path.as_deref().and_then(|p| preview_texture(app, ctx, p)); let rows_max_h = (ctx.content_rect().height() * 0.3).clamp(90.0, 320.0); + let width = (ctx.content_rect().width() - 40.0).clamp(1.0, 760.0); egui::Window::new(tl!("Link Media")) .id(egui::Id::new("Link Media")) .collapsible(false) .resizable(false) - .default_width(760.0) + .default_width(width) + .max_width(width) .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) .show(ctx, |ui| { - ui.label(tlf!("{n} clip(s) can't find their media. Locate them, search a folder, or leave them offline.", n = rows.len())); - ui.add_space(6.0); - egui::ScrollArea::vertical().id_salt("link-media-rows-scroll").max_height(rows_max_h).auto_shrink([false, true]).show(ui, |ui| { - egui::Grid::new("link-media-rows").num_columns(4).striped(true).spacing([14.0, 4.0]).show(ui, |ui| { - for h in [tl!("Clip Name"), tl!("File Name"), tl!("File Path"), tl!("Status")] { - ui.label(RichText::new(h).strong()); - } - ui.end_row(); - for (k, (_, name, fname, path, status)) in rows.iter().enumerate() { - let r = ui.selectable_label(d.row == k, name); - push(&mut elems, format!("linkMedia.row.{k}"), &r, name.clone()); - if r.clicked() && d.row != k { - d.row = k; - d.candidates.clear(); - d.candidate = None; + egui::ScrollArea::vertical().id_salt("link-media-body-scroll").max_height((ctx.content_rect().height() - 140.0).max(1.0)).show(ui, |ui| { + ui.label(tlf!("{n} clip(s) can't find their media. Locate them, search a folder, or leave them offline.", n = rows.len())); + ui.add_space(6.0); + egui::ScrollArea::both().id_salt("link-media-rows-scroll").max_height(rows_max_h).auto_shrink([false, true]).show(ui, |ui| { + egui::Grid::new("link-media-rows").num_columns(4).striped(true).spacing([14.0, 4.0]).show(ui, |ui| { + for h in [tl!("Clip Name"), tl!("File Name"), tl!("File Path"), tl!("Status")] { + ui.label(RichText::new(h).strong()); } - ui.label(fname); - ui.label(RichText::new(path).small()); - ui.label(RichText::new(*status).color(Color32::from_rgb(0xe0, 0x5a, 0x5a))); ui.end_row(); - } - }); - }); - ui.separator(); - ui.horizontal(|ui| { - ui.label(tl!("Match file properties:")); - for (id, label, v) in [ - ("fileName", tl!("File Name"), &mut d.file_name), - ("extension", tl!("File Extension"), &mut d.extension), - ("clipId", tl!("Clip ID (fingerprint)"), &mut d.clip_id), - ("duration", tl!("Duration"), &mut d.duration), - ("mediaStart", tl!("Media Start"), &mut d.media_start), - ("metadata", tl!("Frame Size / Rate"), &mut d.metadata), - ] { - let r = ui.checkbox(v, label); - push(&mut elems, format!("linkMedia.match.{id}"), &r, label); - } - }); - ui.horizontal(|ui| { - let r = ui.checkbox(&mut d.align_timecode, tl!("Align Timecode")); - push(&mut elems, "linkMedia.alignTimecode", &r, "Align Timecode"); - let r = ui.checkbox(&mut d.relink_others, tl!("Relink others automatically")); - push(&mut elems, "linkMedia.relinkOthers", &r, "Relink others automatically"); - }); - ui.separator(); - ui.horizontal(|ui| { - ui.label(tl!("Search in:")); - let r = ui.add(egui::TextEdit::singleline(&mut d.folder).desired_width(360.0).hint_text(tl!("Folder"))); - push(&mut elems, "linkMedia.folder", &r, "folder"); - let r = ui.button(tl!("Browse…")); - push(&mut elems, "linkMedia.browse", &r, "Browse…"); - if r.clicked() { - action = Some("browse"); - } - let r = ui.checkbox(&mut d.exact_name, tl!("Exact name matches only")); - push(&mut elems, "linkMedia.exactName", &r, "Exact name matches only"); - let r = ui.button(tl!("Search")); - push(&mut elems, "linkMedia.search", &r, "Search"); - if r.clicked() { - action = Some("search"); - } - }); - ui.horizontal(|ui| { - ui.vertical(|ui| { - ui.set_width(520.0); - if d.candidates.is_empty() { - ui.label(RichText::new(tl!("No candidates yet — Search a folder or Locate the file.")).weak()); - } - egui::ScrollArea::vertical().id_salt("link-media-candidates-scroll").max_height(108.0).auto_shrink([false, true]).show(ui, |ui| { - for (k, (path, ok, idm, problems)) in d.candidates.iter().enumerate() { - let mark = match (ok, idm) { - (true, Some(true)) => tl!("✔ same file"), - (true, _) => tl!("✔ matches"), - (false, Some(false)) => tl!("✖ different file"), - _ => "✖", - }; - let text = format!("{mark} {path}"); - let r = ui.selectable_label( - d.candidate == Some(k), - RichText::new(&text).color(if *ok { Color32::LIGHT_GREEN } else { Color32::from_rgb(0xe0, 0x8a, 0x6a) }), - ); - let r = if problems.is_empty() { r } else { r.on_hover_text(problems) }; - push(&mut elems, format!("linkMedia.candidate.{k}"), &r, text); - if r.clicked() { - d.candidate = Some(k); + for (k, (_, name, fname, path, status)) in rows.iter().enumerate() { + let r = ui.selectable_label(d.row == k, name); + push(&mut elems, format!("linkMedia.row.{k}"), &r, name.clone()); + if r.clicked() && d.row != k { + d.row = k; + d.candidates.clear(); + d.candidate = None; } + ui.label(fname); + ui.label(RichText::new(path).small()); + ui.label(RichText::new(*status).color(Color32::from_rgb(0xe0, 0x5a, 0x5a))); + ui.end_row(); } }); }); - let (rect, _) = ui.allocate_exact_size(egui::vec2(192.0, 108.0), egui::Sense::hover()); - ui.painter().rect_filled(rect, 3.0, Color32::from_rgb(12, 12, 12)); - match &preview { - Some(t) => { - let fitted = crate::panels::monitor::fit(rect, t.size()[0] as f32, t.size()[1] as f32); - ui.painter().image(t.id(), fitted, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), Color32::WHITE); + ui.separator(); + ui.horizontal_wrapped(|ui| { + ui.label(tl!("Match file properties:")); + for (id, label, v) in [ + ("fileName", tl!("File Name"), &mut d.file_name), + ("extension", tl!("File Extension"), &mut d.extension), + ("clipId", tl!("Clip ID (fingerprint)"), &mut d.clip_id), + ("duration", tl!("Duration"), &mut d.duration), + ("mediaStart", tl!("Media Start"), &mut d.media_start), + ("metadata", tl!("Frame Size / Rate"), &mut d.metadata), + ] { + let r = ui.checkbox(v, label); + push(&mut elems, format!("linkMedia.match.{id}"), &r, label); } - None => { - ui.painter().text(rect.center(), egui::Align2::CENTER_CENTER, tl!("Preview"), crate::theme::Tokens::ui(11.0), Color32::GRAY); + }); + ui.horizontal_wrapped(|ui| { + let r = ui.checkbox(&mut d.align_timecode, tl!("Align Timecode")); + push(&mut elems, "linkMedia.alignTimecode", &r, "Align Timecode"); + let r = ui.checkbox(&mut d.relink_others, tl!("Relink others automatically")); + push(&mut elems, "linkMedia.relinkOthers", &r, "Relink others automatically"); + }); + ui.separator(); + ui.horizontal_wrapped(|ui| { + ui.label(tl!("Search in:")); + let r = ui.add(egui::TextEdit::singleline(&mut d.folder).desired_width((ui.available_width() * 0.5).min(360.0)).hint_text(tl!("Folder"))); + push(&mut elems, "linkMedia.folder", &r, "folder"); + let r = ui.button(tl!("Browse…")); + push(&mut elems, "linkMedia.browse", &r, "Browse…"); + if r.clicked() { + action = Some("browse"); } + let r = ui.checkbox(&mut d.exact_name, tl!("Exact name matches only")); + push(&mut elems, "linkMedia.exactName", &r, "Exact name matches only"); + let r = ui.button(tl!("Search")); + push(&mut elems, "linkMedia.search", &r, "Search"); + if r.clicked() { + action = Some("search"); + } + }); + ui.horizontal(|ui| { + let candidates_width = (ui.available_width() - 192.0 - ui.spacing().item_spacing.x).max(1.0); + ui.vertical(|ui| { + ui.set_width(candidates_width); + if d.candidates.is_empty() { + ui.label(RichText::new(tl!("No candidates yet — Search a folder or Locate the file.")).weak()); + } + egui::ScrollArea::vertical().id_salt("link-media-candidates-scroll").max_height(108.0).auto_shrink([false, true]).show(ui, |ui| { + for (k, (path, ok, idm, problems)) in d.candidates.iter().enumerate() { + let mark = match (ok, idm) { + (true, Some(true)) => tl!("✔ same file"), + (true, _) => tl!("✔ matches"), + (false, Some(false)) => tl!("✖ different file"), + _ => "✖", + }; + let text = format!("{mark} {path}"); + let r = ui.selectable_label( + d.candidate == Some(k), + RichText::new(&text).color(if *ok { Color32::LIGHT_GREEN } else { Color32::from_rgb(0xe0, 0x8a, 0x6a) }), + ); + let r = if problems.is_empty() { r } else { r.on_hover_text(problems) }; + push(&mut elems, format!("linkMedia.candidate.{k}"), &r, text); + if r.clicked() { + d.candidate = Some(k); + } + } + }); + }); + let (rect, _) = ui.allocate_exact_size(egui::vec2(192.0, 108.0), egui::Sense::hover()); + ui.painter().rect_filled(rect, 3.0, Color32::from_rgb(12, 12, 12)); + match &preview { + Some(t) => { + let fitted = crate::panels::monitor::fit(rect, t.size()[0] as f32, t.size()[1] as f32); + ui.painter().image(t.id(), fitted, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), Color32::WHITE); + } + None => { + ui.painter().text(rect.center(), egui::Align2::CENTER_CENTER, tl!("Preview"), crate::theme::Tokens::ui(11.0), Color32::GRAY); + } + } + elems.push(("linkMedia.preview".into(), rect, "preview".into())); + }); + if !d.message.is_empty() { + ui.colored_label(Color32::from_rgb(0xe0, 0x8a, 0x6a), &d.message); } - elems.push(("linkMedia.preview".into(), rect, "preview".into())); }); - if !d.message.is_empty() { - ui.colored_label(Color32::from_rgb(0xe0, 0x8a, 0x6a), &d.message); - } ui.add_space(6.0); - ui.horizontal(|ui| { + ui.horizontal_wrapped(|ui| { for (id, label) in [("offlineAll", tl!("Offline All")), ("offline", tl!("Offline")), ("cancel", tl!("Cancel")), ("locate", tl!("Locate…"))] { let r = ui.button(label); push(&mut elems, format!("linkMedia.{id}"), &r, label); diff --git a/crates/ui-egui/tests/media_ui.rs b/crates/ui-egui/tests/media_ui.rs index 80246ef0..c36dc8ca 100644 --- a/crates/ui-egui/tests/media_ui.rs +++ b/crates/ui-egui/tests/media_ui.rs @@ -351,6 +351,43 @@ fn source_menu_steps_preserve_zero_tick_frame_duration_cursor() { } } +#[test] +fn link_media_large_lists_and_long_paths_keep_actions_on_screen() { + for (width, height) in [(1600.0, 980.0), (1024.0, 768.0), (800.0, 600.0)] { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + let mut p = (*s.project).clone(); + let template = p.items.values().find_map(|i| i.as_media()).unwrap().clone(); + for n in 0..100 { + let mut media = template.clone(); + media.media = MediaRef::File { path: format!("/missing/{}/clip-{n}.mov", "long-project-directory/".repeat(20)) }; + media.offline = true; + let id = p.add_item(&format!("Missing clip {n}"), Label::Iris, ItemKind::Media(media), None); + s.offline.missing.push(id); + } + s.project = Arc::new(p); + let mut app = FilmcraftApp::new(s); + app.ui.link_media = Some(Default::default()); + let mut builder = Harness::builder().with_size(egui::vec2(width, height)).with_max_steps(10_000); + let snapshots = std::env::var_os("FILMCRAFT_UI_SNAPSHOT_DIR").map(PathBuf::from); + if snapshots.is_some() { + builder = builder.wgpu(); + } + let mut h = builder.build_eframe(move |_| app); + for _ in 0..8 { + h.step(); + } + for id in ["linkMedia.locate", "linkMedia.cancel", "linkMedia.offlineAll", "linkMedia.link"] { + let r = h.state().auto.find(id).unwrap().rect; + assert!(r[0] >= 0.0 && r[1] >= 0.0 && r[0] + r[2] <= width && r[1] + r[3] <= height, "{id} at {r:?} outside {width}x{height}"); + } + if let Some(dir) = snapshots { + std::fs::create_dir_all(&dir).unwrap(); + h.render().unwrap().save(dir.join(format!("media-long-paths-{width}x{height}.png"))).unwrap(); + } + } +} + #[test] fn link_media_dialog_relinks_moved_media() { let root = tmp_dir("link"); From 86856f18b9d2f8f53a965f30f26181b156a593a5 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 18:49:48 +0200 Subject: [PATCH 056/144] #680: Shift+Delete ripple deletes with the Timeline focused Problem: the focused panel's shortcuts were all tried before the application-wide ones. egui ignores extra Shift and Alt when matching a key press, so with the Timeline focused its `Delete` (Clear, #243) swallowed the app-wide `Shift+Delete` (Ripple Delete) and the clip was removed without closing the gap. Fix: try the most specific chords first across both scopes; the sort is stable, so a panel shortcut still overrides an application shortcut on the same chord. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 9 +++++++-- crates/ui-egui/src/menus.rs | 8 +++++++- crates/ui-egui/tests/keyboard_ui.rs | 24 ++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 857f42a5..86fcb8eb 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1285,7 +1285,10 @@ impl FilmcraftApp { if self.session.trim_play.dynamic.is_some() && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { let _ = self.session.execute("trim.cancelDynamic", json!({})); } - // Panel shortcuts of the focused panel first: they override application shortcuts. + // Panel shortcuts of the focused panel override application shortcuts on the same chord. + // egui ignores extra Shift and Alt when matching, so the most specific chords go first + // across both scopes (the sort is stable, so the panel still wins a tie): otherwise the + // Timeline's `Delete` (Clear) swallows the app-wide `Shift+Delete` (Ripple Delete) (#680). let focused = self.ui.focused.title(); let mut fire = Vec::new(); ctx.input_mut(|i| { @@ -1293,7 +1296,9 @@ impl FilmcraftApp { clipboard_events_as_keys(&mut i.events, modifiers); let panel = self.bindings.iter().filter(|b| b.3.as_deref() == Some(focused)); let app_wide = self.bindings.iter().filter(|b| b.3.is_none()); - for (m, k, id, _) in panel.chain(app_wide) { + let mut candidates: Vec<&menus::KeyBinding> = panel.chain(app_wide).collect(); + candidates.sort_by_key(|b| std::cmp::Reverse(menus::specificity(b.0))); + for (m, k, id, _) in candidates { if i.consume_key(*m, *k) { fire.push(id.clone()); } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9dff12d3..d47a525e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -683,10 +683,16 @@ pub fn bindings(app: &FilmcraftApp) -> Vec { let shifted: Vec = v.iter().filter(|b| b.0.shift).filter_map(|(m, k, id, p)| shifted_key(*k).map(|k2| (*m, k2, id.clone(), p.clone()))).collect(); v.extend(shifted); - v.sort_by_key(|(m, ..)| std::cmp::Reverse(m.command as u8 + m.shift as u8 + m.alt as u8 + m.ctrl as u8)); + v.sort_by_key(|(m, ..)| std::cmp::Reverse(specificity(*m))); v } +/// How many modifiers a chord names: chords naming more are matched first, because egui ignores +/// extra Shift and Alt when matching a key press. +pub fn specificity(m: egui::Modifiers) -> u8 { + m.command as u8 + m.shift as u8 + m.alt as u8 + m.ctrl as u8 +} + /// The key a US layout reports for `k` with Shift held, when it differs. fn shifted_key(k: egui::Key) -> Option { use egui::Key; diff --git a/crates/ui-egui/tests/keyboard_ui.rs b/crates/ui-egui/tests/keyboard_ui.rs index 03660632..52ece399 100644 --- a/crates/ui-egui/tests/keyboard_ui.rs +++ b/crates/ui-egui/tests/keyboard_ui.rs @@ -238,3 +238,27 @@ fn delete_key_clears_the_selected_timeline_clip() { d.key("Cmd+Z"); assert!(items(&mut d).iter().any(|i| i["clip"].as_u64() == Some(clip)), "and undo brings it back"); } + +#[test] +fn shift_delete_ripple_deletes_with_the_timeline_focused() { + // #680: the Timeline's own `Delete` (Clear) was tried before the app-wide `Shift+Delete` + // (Ripple Delete), and egui ignores the extra Shift, so Shift+Delete left a gap. + let mut d = Driver::new(); + // the demo's other tracks have clips in the way of a sync-locked ripple + for t in ["V2", "V3", "A2", "A3"] { + d.exec("timeline.setTrack", json!({"track": t, "syncLock": false})); + } + let items = |d: &mut Driver| d.exec("sequence.inspect", json!({}))["video"][0]["items"].as_array().unwrap().clone(); + let v1 = items(&mut d); + let (clip, start) = (v1[1]["clip"].as_u64().unwrap(), v1[1]["start"].as_i64().unwrap()); + let next = v1[2]["clip"].as_u64().unwrap(); + d.exec("playhead.set", json!({"time": start + 1_000_000_000})); + d.focus("Timeline"); + d.key("D"); + assert!(d.app().session.state.selection.iter().any(|c| c.0 == clip), "D selects the clip under the playhead"); + d.key("Shift+Delete"); + let after = items(&mut d); + assert!(!after.iter().any(|i| i["clip"].as_u64() == Some(clip)), "Shift+Delete removed the selected clip"); + let moved = after.iter().find(|i| i["clip"].as_u64() == Some(next)).map(|i| i["start"].as_i64().unwrap()); + assert_eq!(moved, Some(start), "and closed the gap"); +} From 5763460a6205800aa960dd100e301d4dda4e8591 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:52:06 +0700 Subject: [PATCH 057/144] fix(captions): reject overflowing TTML times and round extreme clocks safely --- crates/captions/src/lib.rs | 5 ++++- crates/captions/src/ttml.rs | 39 ++++++++++++++++++++++++++++++++++--- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/crates/captions/src/lib.rs b/crates/captions/src/lib.rs index 87ef1e56..b4d4758b 100644 --- a/crates/captions/src/lib.rs +++ b/crates/captions/src/lib.rs @@ -307,7 +307,8 @@ pub fn parse_clock(s: &str) -> Option { /// Format ticks as `HH:MM:SSmmm` (rounded to the nearest millisecond). pub fn format_clock(t: Tick, sep: char) -> String { - let ms = (t.0.max(0) + TICKS_PER_MS / 2) / TICKS_PER_MS; + // The rounding addition must not overflow for an input-derived Tick near i64::MAX. + let ms = (i128::from(t.0.max(0)) + i128::from(TICKS_PER_MS) / 2) / i128::from(TICKS_PER_MS); let (h, m, s, f) = (ms / 3_600_000, (ms / 60_000) % 60, (ms / 1000) % 60, ms % 1000); format!("{h:02}:{m:02}:{s:02}{sep}{f:03}") } @@ -368,6 +369,8 @@ mod tests { assert_eq!(parse_clock("00:61:00,000"), None); assert_eq!(parse_clock("aa:00:00,000"), None); assert_eq!(format_clock(Tick(TICKS_PER_SECOND * 3723 + TICKS_PER_MS * 45), ','), "01:02:03,045"); + assert!(format_clock(Tick(i64::MAX), ',').ends_with(",077")); + assert_eq!(format_clock(Tick(i64::MIN), '.'), "00:00:00.000"); } #[test] diff --git a/crates/captions/src/ttml.rs b/crates/captions/src/ttml.rs index 1109890e..faff9dce 100644 --- a/crates/captions/src/ttml.rs +++ b/crates/captions/src/ttml.rs @@ -252,6 +252,10 @@ fn parse_time(s: &str, tm: Timing) -> Option { let (h, m) = (num(parts.first()?)?, num(parts.get(1)?)?); let sec_part = parts.get(2)?; let (si, sf) = sec_part.split_once('.').unwrap_or((sec_part, "")); + let seconds = num(si)?; + if m >= 60 || seconds >= 60 { + return None; + } let mut t = (h * 3600 + m * 60) * tps + decimal_ticks(si, sf, tps)?; if let Some(fp) = parts.get(3) { if parts.len() > 4 { @@ -259,13 +263,15 @@ fn parse_time(s: &str, tm: Timing) -> Option { } let (fi, fs) = fp.split_once('.').unwrap_or((fp, "")); let frames = num(fi)?; - let _ = tm.base; let sub: i128 = if fs.is_empty() { 0 } else { num(fs)? }; + if frames >= i128::from(tm.base) || sub >= i128::from(tm.sub_frame_rate) { + return None; + } // frames at the effective rate; sub-frames divide a frame let ft = tm.rate.tick_of(1).0 as i128; t += frames * ft + sub * ft / tm.sub_frame_rate.max(1) as i128; } - return Some(Tick(t as i64)); + return i64::try_from(t).ok().map(Tick); } let unit_start = s.find(|c: char| c.is_ascii_alphabetic())?; let (v, unit) = s.split_at(unit_start); @@ -290,7 +296,7 @@ fn parse_time(s: &str, tm: Timing) -> Option { } _ => return None, }; - Some(Tick(t as i64)) + i64::try_from(t).ok().map(Tick) } #[derive(Clone, Copy, Default, PartialEq)] @@ -552,6 +558,33 @@ mod tests { assert_eq!(parse_time("1:2", t), None); } + #[test] + fn invalid_and_overflowed_time_expressions_are_ignored() { + let normal = tm(FrameRate::FPS_25); + for expr in [ + "999999999999999h", + "999999999999999m", + "999999999999999s", + "999999999999999ms", + "999999999999999f", + "999999999999999t", + "00:60:00", + "00:00:60", + "00:00:01:25", + ] { + assert_eq!(parse_time(expr, normal), None, "{expr}"); + } + let subframes = Timing { sub_frame_rate: 2, ..normal }; + assert!(parse_time("00:00:01:12.1", subframes).is_some()); + assert_eq!(parse_time("00:00:01:12.2", subframes), None); + // Bad cues must not wrap to a plausible but unrelated timeline position. + let xml = r#"

Bad

Good

"#; + let doc = parse(xml).expect("well-formed TTML"); + assert_eq!(doc.cues.len(), 1); + assert_eq!(doc.cues[0].text, "Good"); + assert!(!doc.warnings.is_empty()); + } + #[test] fn reads_styles_spans_and_nesting() { let x = r#" From 878000df077cc1e2fdb2fcb0bfb174075bb3dc98 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 18:54:30 +0200 Subject: [PATCH 058/144] #279: WAV opens in place and reads only the samples asked for Problem: WAV had no streaming reader, so open_reader fell back to reading the whole file into memory, and WavSource kept those bytes for its lifetime. Importing a 90-minute WAV cost about its size in memory and blocked until the whole file was read. Fix: WavSource reads through a SharedReader. Opening walks the chunk headers (fmt, data, bext, iXML) without touching the samples, and audio() reads only the frame range a request needs, in bounded blocks. open_reader takes WAV this way after the registered reader openers; WavSource::parse keeps working on in-memory bytes. Co-Authored-By: Claude Opus 5.5 --- crates/media/src/reader.rs | 79 ++++++++++------ crates/media/src/wav.rs | 182 ++++++++++++++++++++++++++----------- 2 files changed, 182 insertions(+), 79 deletions(-) diff --git a/crates/media/src/reader.rs b/crates/media/src/reader.rs index 2aeb47bf..6ec25fae 100644 --- a/crates/media/src/reader.rs +++ b/crates/media/src/reader.rs @@ -2,8 +2,9 @@ //! //! A [`ByteReader`] serves reads at any offset (a native file, a web `Blob` read in chunks, an //! in-memory buffer). Container openers that only need the index and then single samples (MP4/MOV, -//! Matroska) open from a reader ([`ReaderOpener`]); formats decoded in one go (stills, WAV, -//! standalone compressed audio) fall back to reading the whole file through it. +//! Matroska) open from a reader ([`ReaderOpener`]), as does WAV (header now, sample ranges on +//! demand); formats decoded in one go (stills, standalone compressed audio) fall back to reading +//! the whole file through it. use std::io; use std::sync::Arc; @@ -109,15 +110,15 @@ pub type ReaderOpener = fn(name: &str, head: &[u8], reader: &SharedReader) -> Op /// Bytes sniffed by [`open_reader`]. pub const HEAD_LEN: usize = 64 * 1024; -/// Open media from a reader: reader openers first; otherwise read the whole file and use the -/// byte openers (`extra`, then stills / WAV). +/// Open media from a reader: reader openers first, then WAV (read in place); otherwise read the +/// whole file and use the byte openers (`extra`, then stills). pub fn open_reader(name: &str, reader: SharedReader, reader_openers: &[ReaderOpener], extra: &[Opener]) -> Result { open_reader_within(name, reader, reader_openers, extra, u64::MAX) } /// Like [`open_reader`], but a file no reader opener takes is read whole only up to `max_whole` /// bytes; a larger one is refused instead. For looking at files rather than importing them (the -/// Media Browser's properties and thumbnails, #157): reading a multi-gigabyte AVI or WAV into +/// Media Browser's properties and thumbnails, #157): reading a multi-gigabyte AVI into /// memory to show its duration, or to find out it isn't supported, thrashes the disk. pub fn open_reader_within(name: &str, reader: SharedReader, reader_openers: &[ReaderOpener], extra: &[Opener], max_whole: u64) -> Result { let head = read_range(&*reader, 0, HEAD_LEN).map_err(|e| MediaError::Io(format!("{name}: {e}")))?; @@ -126,6 +127,9 @@ pub fn open_reader_within(name: &str, reader: SharedReader, reader_openers: &[Re return r; } } + if crate::wav::sniff(&head) { + return Ok(Arc::new(crate::wav::WavSource::open(name, reader)?)); + } if reader.len() > max_whole { return Err(MediaError::Unsupported(format!("{name}: no streaming reader for this format, and it is too large to read whole here"))); } @@ -181,35 +185,58 @@ mod tests { } } - /// #157: looking at a file no streaming reader takes (an AVI, a large WAV) read all of it. - /// Within a limit it reads the head only and refuses; a small file still opens. + /// #157: looking at a file no streaming reader takes (an AVI) read all of it. Within a limit + /// it reads the head only and refuses. #[test] fn open_within_reads_only_the_head_of_large_unstreamable_files() { - let mut wav = crate::wav::write_wav16(&vec![0.25; 48_000 * 2], 2, 48_000); - let small = wav.len() as u64; - let r = Arc::new(Counting(MemReader(Arc::from(wav.clone())), Default::default())); - let src = open_reader_within("a.wav", r.clone(), &[], &[], small).unwrap(); - assert_eq!(src.info().audio().unwrap().channels, 2); - // the same file over the limit: refused after reading the sniffing head only - let r = Arc::new(Counting(MemReader(Arc::from(wav.clone())), Default::default())); - let e = open_reader_within("a.wav", r.clone(), &[], &[], small - 1).err().unwrap(); + let mut avi = vec![0u8; 4 * HEAD_LEN]; + avi[..4].copy_from_slice(b"RIFF"); + avi[8..12].copy_from_slice(b"AVI "); + let r = Arc::new(Counting(MemReader(Arc::from(avi)), Default::default())); + let e = open_reader_within("a.avi", r.clone(), &[], &[], HEAD_LEN as u64).err().unwrap(); assert!(matches!(e, MediaError::Unsupported(_)), "{e:?}"); assert!(r.1.load(std::sync::atomic::Ordering::SeqCst) <= HEAD_LEN as u64); - // an unsupported format is refused without being read whole either - wav.resize(4 * HEAD_LEN, 0); - wav[..4].copy_from_slice(b"RIFF"); - wav[8..12].copy_from_slice(b"AVI "); + } + + /// #279: a WAV is read in place, never whole: opening it reads the sniffing head and its chunk + /// headers, and audio reads only the requested samples, however large the file (and over the + /// Media Browser's whole-file limit too). + #[test] + fn wav_opens_in_place_and_reads_only_requested_samples() { + let rate = 48_000usize; + let samples: Vec = (0..rate * 2 * 20).map(|i| ((i / 2) % 100) as f32 / 200.0).collect(); + let wav = crate::wav::write_wav16(&samples, 2, rate as u32); + let size = wav.len() as u64; + assert!(size > 3 * HEAD_LEN as u64); let r = Arc::new(Counting(MemReader(Arc::from(wav)), Default::default())); - assert!(open_reader_within("a.avi", r.clone(), &[], &[], HEAD_LEN as u64).is_err()); - assert!(r.1.load(std::sync::atomic::Ordering::SeqCst) <= HEAD_LEN as u64); + let read = || r.1.load(std::sync::atomic::Ordering::SeqCst); + let src = open_reader_within("a.wav", r.clone(), &[], &[], HEAD_LEN as u64).unwrap(); + assert_eq!(src.info().audio().unwrap().channels, 2); + assert_eq!(src.info().duration, filmcraft_time::Tick::from_units(20, 1)); + assert_eq!(src.info().file_size, Some(size)); + let after_open = read(); + assert!(after_open <= HEAD_LEN as u64 + 64, "{after_open}"); + // 10 ms from the middle: about that much is read, and the samples are right + let a = src.audio(rate as i64 * 10 + 7, 480, rate as u32).unwrap(); + assert!(read() - after_open <= 481 * 4, "{}", read() - after_open); + let frame = rate * 10 + 7 + 3; + assert!((a.channels[0][3] - samples[frame * 2]).abs() < 1e-4); + assert!((a.channels[1][3] - samples[frame * 2 + 1]).abs() < 1e-4); + // past the end: silence, nothing read + let before = read(); + let a = src.audio(rate as i64 * 30, 16, rate as u32).unwrap(); + assert!(a.channels[0].iter().all(|&s| s == 0.0)); + assert_eq!(read(), before); } #[test] fn falls_back_to_byte_openers() { - // a WAV is opened through the whole-file path - let wav = crate::wav::write_wav16(&[0.0, 0.5, -0.5, 0.25], 2, 48_000); - let r: SharedReader = Arc::new(MemReader(wav.into())); - let s = open_reader("a.wav", r, &[], &[]).unwrap(); - assert!(s.info().has_audio()); + // a format without a reader opener is read whole and handed to the byte openers + fn whole(_: &str, bytes: Arc<[u8]>) -> Option> { + Some(Err(MediaError::Unsupported(format!("got {} bytes", bytes.len())))) + } + let r: SharedReader = Arc::new(MemReader(Arc::from(&b"bytes"[..]))); + let e = open_reader("a.xyz", r, &[], &[whole]).err().unwrap(); + assert!(matches!(&e, MediaError::Unsupported(m) if m == "got 5 bytes"), "{e:?}"); } } diff --git a/crates/media/src/wav.rs b/crates/media/src/wav.rs index 6b63bf4a..4a0df3d2 100644 --- a/crates/media/src/wav.rs +++ b/crates/media/src/wav.rs @@ -7,17 +7,32 @@ use std::sync::Arc; use filmcraft_frame::{AudioBuffer, VideoFrame}; use filmcraft_time::Tick; +use crate::reader::{MemReader, SharedReader, read_range}; use crate::{AudioStreamInfo, FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource, Result}; pub fn sniff(b: &[u8]) -> bool { b.len() >= 12 && (&b[0..4] == b"RIFF" || &b[0..4] == b"RF64") && &b[8..12] == b"WAVE" } +/// Chunks walked looking for `fmt `/`data`/`bext`/`iXML` (real files have a handful). +const MAX_CHUNKS: usize = 1 << 16; +/// Bytes of an iXML chunk searched for the time reference. +const IXML_MAX: u64 = 1 << 20; +/// Output frames converted per read of the data chunk. +const BLOCK_FRAMES: usize = 4096; +/// Largest single read of the data chunk; a wider span (an extreme resampling ratio) reads the +/// two frames each output frame needs instead. +const MAX_SPAN_BYTES: u64 = 4 << 20; + +/// A WAV file read in place: opening it reads its chunk headers (not its samples), and +/// [`MediaSource::audio`] reads only the sample range it is asked for, so a 90-minute recording +/// costs its header rather than its size (#279). pub struct WavSource { info: MediaInfo, - bytes: Arc<[u8]>, - data_off: usize, - data_len: usize, + reader: SharedReader, + data_off: u64, + /// Whole frames only. + frames: u64, channels: usize, bits: u16, float: bool, @@ -26,63 +41,80 @@ pub struct WavSource { } fn le16(b: &[u8], o: usize) -> u16 { - u16::from_le_bytes([b[o], b[o + 1]]) + b.get(o..o.saturating_add(2)).and_then(|s| s.try_into().ok()).map_or(0, u16::from_le_bytes) } fn le32(b: &[u8], o: usize) -> u32 { - u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]) + b.get(o..o.saturating_add(4)).and_then(|s| s.try_into().ok()).map_or(0, u32::from_le_bytes) } impl WavSource { + /// Parse a WAV file held in memory. pub fn parse(name: &str, bytes: Arc<[u8]>) -> Result { - let b = &bytes[..]; - let mut pos = 12; + Self::open(name, Arc::new(MemReader(bytes))) + } + + /// Open a WAV file through a random-access reader. + pub fn open(name: &str, reader: SharedReader) -> Result { + let io = |e: std::io::Error| MediaError::Io(format!("{name}: {e}")); + let len = reader.len(); + let mut pos: u64 = 12; let mut fmt = None; let mut data = None; let mut bext_tr = None; let mut ixml_tr = None; - while pos + 8 <= b.len() { - let id = &b[pos..pos + 4]; - let mut len = le32(b, pos + 4) as usize; - let body = pos + 8; - if id == b"data" && (len == 0xFFFF_FFFF || body + len > b.len()) { - len = b.len() - body; + for _ in 0..MAX_CHUNKS { + let Some(body) = pos.checked_add(8).filter(|&b| b <= len) else { break }; + let mut hdr = [0u8; 8]; + reader.read_at(pos, &mut hdr).map_err(io)?; + let id = &hdr[0..4]; + let mut clen = u64::from(le32(&hdr, 4)); + let rest = len - body; + if id == b"data" && (clen == 0xFFFF_FFFF || clen > rest) { + clen = rest; } - if body + len > b.len() { + if clen > rest { break; } match id { - b"fmt " if len >= 16 => { - let mut tag = le16(b, body); - let ch = le16(b, body + 2); - let rate = le32(b, body + 4); - let bits = le16(b, body + 14); - if tag == 0xFFFE && len >= 40 { - tag = le16(b, body + 24); + b"fmt " if clen >= 16 => { + let b = read_range(&*reader, body, clen.min(40) as usize).map_err(io)?; + let mut tag = le16(&b, 0); + let ch = le16(&b, 2); + let rate = le32(&b, 4); + let bits = le16(&b, 14); + if tag == 0xFFFE && clen >= 40 { + tag = le16(&b, 24); } fmt = Some((tag, ch, rate, bits)); } - b"data" => data = Some((body, len)), + b"data" => data = Some((body, clen)), // bext: Description 256, Originator 32, OriginatorReference 32, OriginationDate 10, // OriginationTime 8, then TimeReference (low u32, high u32) - b"bext" if len >= 346 => bext_tr = Some(le32(b, body + 338) as u64 | (le32(b, body + 342) as u64) << 32), - b"iXML" => ixml_tr = ixml_time_reference(&b[body..body + len]), + b"bext" if clen >= 346 => { + let b = read_range(&*reader, body + 338, 8).map_err(io)?; + bext_tr = Some(u64::from(le32(&b, 0)) | u64::from(le32(&b, 4)) << 32); + } + b"iXML" => { + let b = read_range(&*reader, body, clen.min(IXML_MAX) as usize).map_err(io)?; + ixml_tr = ixml_time_reference(&b); + } _ => {} } - pos = body + len + (len & 1); + pos = body + clen + (clen & 1); } let (tag, ch, rate, bits) = fmt.ok_or_else(|| MediaError::Decode(format!("{name}: missing fmt chunk")))?; - let (off, len) = data.ok_or_else(|| MediaError::Decode(format!("{name}: missing data chunk")))?; + let (off, dlen) = data.ok_or_else(|| MediaError::Decode(format!("{name}: missing data chunk")))?; let float = tag == 3; if !(tag == 1 || float) || ch == 0 || !matches!(bits, 8 | 16 | 24 | 32 | 64) { return Err(MediaError::Unsupported(format!("{name}: WAV format tag {tag}, {bits} bits"))); } - let frame_bytes = ch as usize * bits as usize / 8; - let frames = len / frame_bytes; + let frame_bytes = u64::from(ch) * u64::from(bits / 8); + let frames = dlen / frame_bytes; let time_reference = bext_tr.or(ixml_tr); let mut info = MediaInfo { name: name.into(), kind: MediaKind::AudioOnly, - duration: Tick::from_units(frames as i64, rate as i64), + duration: Tick::from_units(i64::try_from(frames).unwrap_or(i64::MAX), rate as i64), video: None, audio_streams: vec![AudioStreamInfo { sample_rate: rate, @@ -92,16 +124,16 @@ impl WavSource { }], container: if time_reference.is_some() { "Broadcast WAV".into() } else { "WAV".into() }, start_timecode: None, - file_size: Some(b.len() as u64), + file_size: Some(len), }; // start timecode: frames at the media's frame rate (the default rate for audio-only media) if let Some(tr) = time_reference.filter(|_| rate > 0) { let r = info.frame_rate(); let n = tr as i128 * r.num as i128; let d = rate as i128 * r.den as i128; - info.start_timecode = Some(((n + d / 2) / d) as i64); + info.start_timecode = (n + d / 2).checked_div(d).and_then(|t| i64::try_from(t).ok()); } - Ok(Self { info, data_off: off, data_len: frames * frame_bytes, channels: ch as usize, bits, float, rate, bytes, time_reference }) + Ok(Self { info, reader, data_off: off, frames, channels: ch as usize, bits, float, rate, time_reference }) } /// Broadcast Wave start time: samples since midnight. @@ -109,17 +141,32 @@ impl WavSource { self.time_reference } - fn sample(&self, frame: usize, ch: usize) -> f32 { + fn frame_bytes(&self) -> usize { + self.channels * (self.bits as usize / 8) + } + + /// Read whole frames `first..=last` of the data chunk. + fn read_frames(&self, first: u64, last: u64) -> Result> { + let fb = self.frame_bytes() as u64; + let n = last.saturating_sub(first).saturating_add(1).saturating_mul(fb); + let off = self.data_off.saturating_add(first.saturating_mul(fb)); + let mut buf = vec![0u8; usize::try_from(n).map_err(|_| MediaError::Decode(format!("{}: audio read too large", self.info.name)))?]; + self.reader.read_at(off, &mut buf).map_err(|e| MediaError::Io(format!("{}: {e}", self.info.name)))?; + Ok(buf) + } + + /// Sample `ch` of frame `frame` of `block`, a run of whole frames. + fn sample(&self, block: &[u8], frame: usize, ch: usize) -> f32 { let bps = self.bits as usize / 8; - let o = self.data_off + (frame * self.channels + ch) * bps; - let b = &self.bytes; - match (self.bits, self.float) { - (8, _) => (b[o] as f32 - 128.0) / 128.0, - (16, _) => i16::from_le_bytes([b[o], b[o + 1]]) as f32 / 32768.0, - (24, _) => ((i32::from_le_bytes([0, b[o], b[o + 1], b[o + 2]]) >> 8) as f32) / 8_388_608.0, - (32, false) => i32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]) as f32 / 2_147_483_648.0, - (32, true) => f32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]), - (64, true) => f64::from_le_bytes(b[o..o + 8].try_into().unwrap_or([0; 8])) as f32, + let o = (frame * self.channels + ch) * bps; + let Some(b) = block.get(o..o.saturating_add(bps)) else { return 0.0 }; + match (self.bits, self.float, b) { + (8, _, &[a]) => (a as f32 - 128.0) / 128.0, + (16, _, &[a, b]) => i16::from_le_bytes([a, b]) as f32 / 32768.0, + (24, _, &[a, b, c]) => ((i32::from_le_bytes([0, a, b, c]) >> 8) as f32) / 8_388_608.0, + (32, false, &[a, b, c, d]) => i32::from_le_bytes([a, b, c, d]) as f32 / 2_147_483_648.0, + (32, true, &[a, b, c, d]) => f32::from_le_bytes([a, b, c, d]), + (64, true, b) => f64::from_le_bytes(b.try_into().unwrap_or([0; 8])) as f32, _ => 0.0, } } @@ -133,25 +180,54 @@ impl MediaSource for WavSource { Err(MediaError::NoStream("video")) } fn audio(&self, start: i64, frames: usize, sample_rate: u32) -> Result { - let total = self.data_len / (self.channels * self.bits as usize / 8); + let total = self.frames; let mut out = AudioBuffer::silence(sample_rate, self.channels, frames); + if total == 0 || sample_rate == 0 { + return Ok(out); + } // Linear-interpolating resample when rates differ (the audio crate provides sinc resampling). let ratio = self.rate as f64 / sample_rate as f64; - for i in 0..frames { - let src = (start + i as i64) as f64 * ratio; - if src < 0.0 { + let src_of = |i: usize| start.saturating_add(i as i64) as f64 * ratio; + let mut i = 0; + while i < frames { + let end = frames.min(i + BLOCK_FRAMES); + let (lo, hi) = (src_of(i), src_of(end - 1)); + if hi < 0.0 { + i = end; continue; } - let i0 = src.floor() as usize; - if i0 >= total { + let first = lo.max(0.0).floor() as u64; + if first >= total { break; } - let fr = (src - i0 as f64) as f32; - for c in 0..self.channels { - let a = self.sample(i0, c); - let b = if i0 + 1 < total { self.sample(i0 + 1, c) } else { a }; - out.channels[c][i] = a + (b - a) * fr; + let last = (hi.floor() as u64).saturating_add(1).min(total - 1); + let span = last.saturating_sub(first).saturating_add(1).saturating_mul(self.frame_bytes() as u64); + let block = if span <= MAX_SPAN_BYTES { Some(self.read_frames(first, last)?) } else { None }; + for slot in i..end { + let src = src_of(slot); + if src < 0.0 { + continue; + } + let i0 = src.floor() as u64; + if i0 >= total { + break; + } + let i1 = (i0 + 1).min(total - 1); + let fr = (src - i0 as f64) as f32; + let (buf, base) = match &block { + Some(b) => (std::borrow::Cow::Borrowed(b.as_slice()), first), + None => (std::borrow::Cow::Owned(self.read_frames(i0, i1)?), i0), + }; + let (k0, k1) = ((i0 - base) as usize, (i1 - base) as usize); + for c in 0..self.channels { + let a = self.sample(&buf, k0, c); + let b = self.sample(&buf, k1, c); + if let Some(s) = out.channels.get_mut(c).and_then(|ch| ch.get_mut(slot)) { + *s = a + (b - a) * fr; + } + } } + i = end; } Ok(out) } From f9f0268b6594568d34857abb84564736920ccf66 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:57:01 +0700 Subject: [PATCH 059/144] fix(captions): keep literal angle-bracket text in EBU STL exports --- crates/captions/src/stl.rs | 44 +++++++++++++++++++++++++++++++------- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/crates/captions/src/stl.rs b/crates/captions/src/stl.rs index ad012395..cc5d1e37 100644 --- a/crates/captions/src/stl.rs +++ b/crates/captions/src/stl.rs @@ -215,15 +215,23 @@ fn encode_text(text: &str) -> Vec { if ch == '<' && let Some(end) = rest.find('>') { - match rest[1..end].trim().to_ascii_lowercase().as_str() { - "i" => out.push(0x80), - "/i" => out.push(0x81), - "u" => out.push(0x82), - "/u" => out.push(0x83), - _ => {} + 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. + let name = tag.strip_prefix('/').unwrap_or(tag); + let is_tag = name.as_bytes().first().is_some_and(u8::is_ascii_alphabetic) + && tag.trim() == tag; + if is_tag { + match tag.to_ascii_lowercase().as_str() { + "i" => out.push(0x80), + "/i" => out.push(0x81), + "u" => out.push(0x82), + "/u" => out.push(0x83), + _ => {} + } + rest = &rest[end + 1..]; + continue; } - rest = &rest[end + 1..]; - continue; } if ch == '\n' { out.push(0x8a); @@ -451,6 +459,26 @@ mod tests { assert!(parse(&b[..500]).is_err()); } + #[test] + fn literal_angle_brackets_are_not_discarded_as_markup() { + let literal = "2 < 3 > 1, x < y > z, and 5 <7> 6"; + let doc = Document { + cues: vec![Cue { + start: FrameRate::FPS_25.tick_of(25), + end: FrameRate::FPS_25.tick_of(75), + text: literal.into(), + ..Default::default() + }], + ..Default::default() + }; + let encoded = write(&doc, Some(FrameRate::FPS_25)); + let decoded = parse(&encoded).expect("valid EBU STL"); + assert_eq!(decoded.cues.len(), 1); + assert_eq!(decoded.cues[0].text, literal); + // Recognized styling still becomes STL control bytes. + assert_eq!(encode_text("Hi"), vec![0x80, b'H', b'i', 0x81]); + } + #[test] fn programme_start_is_subtracted() { let doc = Document { From fc69e9cad94e753b1c89b8a3f5cf13c5764972a1 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:57:43 +0700 Subject: [PATCH 060/144] fix(captions): preserve whitespace-tolerant styling tags --- crates/captions/src/stl.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/captions/src/stl.rs b/crates/captions/src/stl.rs index cc5d1e37..27fa5c07 100644 --- a/crates/captions/src/stl.rs +++ b/crates/captions/src/stl.rs @@ -219,8 +219,7 @@ fn encode_text(text: &str) -> Vec { // Only a syntactically plausible tag is markup. Comparisons such as // "2 < 3 > 1" and "x < y > z" must not lose their middle text. let name = tag.strip_prefix('/').unwrap_or(tag); - let is_tag = name.as_bytes().first().is_some_and(u8::is_ascii_alphabetic) - && tag.trim() == tag; + let is_tag = name.as_bytes().first().is_some_and(u8::is_ascii_alphabetic); if is_tag { match tag.to_ascii_lowercase().as_str() { "i" => out.push(0x80), @@ -477,6 +476,7 @@ mod tests { assert_eq!(decoded.cues[0].text, literal); // Recognized styling still becomes STL control bytes. assert_eq!(encode_text("Hi"), vec![0x80, b'H', b'i', 0x81]); + assert_eq!(encode_text("Hi"), vec![0x80, b'H', b'i', 0x81]); } #[test] From 0133f1d717e0c271ccb61aafd544b18df7e098b6 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:58:13 +0700 Subject: [PATCH 061/144] fix(captions): keep trim-tolerant tag recognition --- crates/captions/src/stl.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/captions/src/stl.rs b/crates/captions/src/stl.rs index 27fa5c07..21fb3353 100644 --- a/crates/captions/src/stl.rs +++ b/crates/captions/src/stl.rs @@ -221,7 +221,7 @@ fn encode_text(text: &str) -> Vec { let name = tag.strip_prefix('/').unwrap_or(tag); let is_tag = name.as_bytes().first().is_some_and(u8::is_ascii_alphabetic); if is_tag { - match tag.to_ascii_lowercase().as_str() { + match tag.trim().to_ascii_lowercase().as_str() { "i" => out.push(0x80), "/i" => out.push(0x81), "u" => out.push(0x82), From 379b9b5decf582e066168b2dcf91b45a4acfeb4f Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 19:00:52 +0200 Subject: [PATCH 062/144] #659: macOS app may use the microphone for voice-over Problem: the macOS bundle had no NSMicrophoneUsageDescription and empty hardened-runtime entitlements, so macOS never asked for microphone access and voice-over recording got no input. Fix: declare NSMicrophoneUsageDescription in Info.plist.in and grant com.apple.security.device.audio-input in entitlements.plist; an xtask test guards both. Co-Authored-By: Claude Opus 5.5 --- packaging/macos/Info.plist.in | 4 ++++ packaging/macos/entitlements.plist | 6 +++++- xtask/src/main.rs | 21 +++++++++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/packaging/macos/Info.plist.in b/packaging/macos/Info.plist.in index 78958249..e9d9dc3b 100644 --- a/packaging/macos/Info.plist.in +++ b/packaging/macos/Info.plist.in @@ -42,6 +42,10 @@ NSRequiresAquaSystemAppearance + + NSMicrophoneUsageDescription + FilmCraft uses the microphone to record voice-over onto the timeline. NSHumanReadableCopyright Copyright © the FilmCraft authors. MIT OR Apache-2.0. diff --git a/packaging/macos/entitlements.plist b/packaging/macos/entitlements.plist index 79fa2237..74540670 100644 --- a/packaging/macos/entitlements.plist +++ b/packaging/macos/entitlements.plist @@ -1,9 +1,13 @@ - + + com.apple.security.device.audio-input + diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 631678d8..648f217b 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -504,3 +504,24 @@ fn main() -> ExitCode { } } } + +#[cfg(test)] +mod tests { + use std::path::Path; + + fn macos_packaging(file: &str) -> String { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../packaging/macos").join(file); + std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display())) + } + + /// Voice-over recording needs both halves on macOS: the usage string (else the permission + /// prompt never appears) and the hardened-runtime audio-input entitlement (else the input is + /// blocked). Regression test for #659. + #[test] + fn macos_bundle_may_use_the_microphone() { + let plist = macos_packaging("Info.plist.in"); + assert!(plist.contains("NSMicrophoneUsageDescription")); + let entitlements = macos_packaging("entitlements.plist"); + assert!(entitlements.contains("com.apple.security.device.audio-input\n ")); + } +} From d8f22e99080d6eeefebdb583c8c7945a2e04b93b Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 19:01:11 +0200 Subject: [PATCH 063/144] #67: media files share a bounded pool of open descriptors Problem: every FileReader owned its std::fs::File for its whole lifetime, so each open media source pinned one descriptor. A project with hundreds of sources hit the soft limit (256 on macOS, 1024 on Linux) and the remaining media failed to open or showed as offline. Fix: FileReader keeps its path and borrows the file from a shared LRU pool of at most MAX_OPEN_FILES (128) open files. Past the cap the least recently read file is closed and its reader reopens it on the next read; a file that is now shorter than when it was opened is refused instead of being read as the old one. Dropping a reader closes its file. Co-Authored-By: Claude Opus 5.5 --- crates/media/src/reader.rs | 151 +++++++++++++++++++++++++++++++++++-- 1 file changed, 145 insertions(+), 6 deletions(-) diff --git a/crates/media/src/reader.rs b/crates/media/src/reader.rs index 2aeb47bf..ac959ad7 100644 --- a/crates/media/src/reader.rs +++ b/crates/media/src/reader.rs @@ -43,24 +43,131 @@ impl ByteReader for MemReader { } } -/// A file on disk, read in place: the file stays open and only the requested ranges are read, so -/// opening a clip costs its index rather than its size. +/// Files [`FileReader`]s keep open at most, together (#67). Each open media source used to hold +/// its own descriptor, so a project with hundreds of sources hit the soft descriptor limit (256 on +/// macOS, 1024 on Linux) and the rest showed as failed or offline. Past this many, the least +/// recently read file is closed and its reader reopens it on demand. +#[cfg(any(unix, windows))] +pub const MAX_OPEN_FILES: usize = 128; + +/// The open files of a set of [`FileReader`]s, least recently used first. +#[cfg(any(unix, windows))] +#[derive(Debug)] +pub(crate) struct FilePool { + cap: usize, + state: std::sync::Mutex, +} + +#[cfg(any(unix, windows))] +#[derive(Debug, Default)] +struct PoolState { + next_id: u64, + open: std::collections::VecDeque<(u64, Arc)>, +} + +#[cfg(any(unix, windows))] +impl FilePool { + pub(crate) fn new(cap: usize) -> Self { + Self { cap: cap.max(1), state: Default::default() } + } + + fn state(&self) -> std::sync::MutexGuard<'_, PoolState> { + self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner) + } + + fn next_id(&self) -> u64 { + let mut s = self.state(); + s.next_id = s.next_id.wrapping_add(1); + s.next_id + } + + /// The open file of reader `id`, now the most recently used. + fn get(&self, id: u64) -> Option> { + let mut s = self.state(); + let at = s.open.iter().position(|(i, _)| *i == id)?; + let entry = s.open.remove(at)?; + let file = entry.1.clone(); + s.open.push_back(entry); + Some(file) + } + + /// Keep `file` open for reader `id`, closing the least recently used files past the cap. A + /// file another thread is reading from stays open until that read ends. + fn insert(&self, id: u64, file: std::fs::File) -> Arc { + let mut s = self.state(); + if let Some((_, open)) = s.open.iter().find(|(i, _)| *i == id) { + return open.clone(); // another thread reopened it first + } + let file = Arc::new(file); + s.open.push_back((id, file.clone())); + while s.open.len() > self.cap { + s.open.pop_front(); + } + file + } + + fn remove(&self, id: u64) { + self.state().open.retain(|(i, _)| *i != id); + } + + #[cfg(test)] + fn open_count(&self) -> usize { + self.state().open.len() + } +} + +#[cfg(any(unix, windows))] +static FILE_POOL: std::sync::LazyLock> = std::sync::LazyLock::new(|| Arc::new(FilePool::new(MAX_OPEN_FILES))); + +/// A file on disk, read in place: only the requested ranges are read, so opening a clip costs its +/// index rather than its size. The file is kept open in a shared pool of at most +/// [`MAX_OPEN_FILES`] and reopened by path when the pool closed it. #[cfg(any(unix, windows))] #[derive(Debug)] pub struct FileReader { - file: std::fs::File, + path: std::path::PathBuf, len: u64, + id: u64, + pool: Arc, } #[cfg(any(unix, windows))] impl FileReader { pub fn open(path: &std::path::Path) -> io::Result { + Self::open_in(path, &FILE_POOL) + } + + pub(crate) fn open_in(path: &std::path::Path, pool: &Arc) -> io::Result { let file = std::fs::File::open(path)?; let m = file.metadata()?; if !m.is_file() { return Err(io::Error::new(io::ErrorKind::NotFound, format!("{} is not a file", path.display()))); } - Ok(Self { file, len: m.len() }) + let id = pool.next_id(); + pool.insert(id, file); + Ok(Self { path: path.to_path_buf(), len: m.len(), id, pool: pool.clone() }) + } + + /// The open file, reopened when the pool closed it. A file now shorter than when it was opened + /// (replaced or truncated) is refused rather than read as the old one; a longer one (still + /// being written) is read up to the opened length, as before. + fn file(&self) -> io::Result> { + if let Some(file) = self.pool.get(self.id) { + return Ok(file); + } + let file = std::fs::File::open(&self.path)?; + let m = file.metadata()?; + if !m.is_file() || m.len() < self.len { + return Err(io::Error::other(format!("{} changed since it was opened", self.path.display()))); + } + Ok(self.pool.insert(self.id, file)) + } +} + +#[cfg(any(unix, windows))] +impl Drop for FileReader { + fn drop(&mut self) { + self.pool.remove(self.id); } } @@ -71,15 +178,16 @@ impl ByteReader for FileReader { } /// Positional reads: threads decoding different parts of the file do not share a cursor. fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { + let file = self.file()?; #[cfg(unix)] { - std::os::unix::fs::FileExt::read_exact_at(&self.file, buf, offset) + std::os::unix::fs::FileExt::read_exact_at(&*file, buf, offset) } #[cfg(windows)] { let (mut done, mut at) = (0usize, offset); while let Some(rest) = buf.get_mut(done..).filter(|r| !r.is_empty()) { - match std::os::windows::fs::FileExt::seek_read(&self.file, rest, at) { + match std::os::windows::fs::FileExt::seek_read(&*file, rest, at) { Ok(0) => return Err(eof()), Ok(n) => { done = done.saturating_add(n); @@ -169,6 +277,37 @@ mod tests { std::fs::remove_dir_all(&dir).unwrap(); } + /// #67: more readers than the pool keeps open stay readable; past the cap the least recently + /// read file is closed and reopened on demand, and a file truncated meanwhile is refused. + #[cfg(any(unix, windows))] + #[test] + fn more_file_readers_than_the_pool_holds_stay_readable() { + let dir = std::env::temp_dir().join(format!("filmcraft-file-pool-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let pool = Arc::new(FilePool::new(2)); + let paths: Vec<_> = (0..5).map(|i| dir.join(format!("{i}.bin"))).collect(); + for (i, p) in paths.iter().enumerate() { + std::fs::write(p, format!("file{i}-0123456789")).unwrap(); + } + let readers: Vec<_> = paths.iter().map(|p| FileReader::open_in(p, &pool).unwrap()).collect(); + assert_eq!(pool.open_count(), 2); + for _ in 0..2 { + for (i, r) in readers.iter().enumerate() { + let mut b = [0u8; 5]; + r.read_at(0, &mut b).unwrap(); + assert_eq!(b, *format!("file{i}").as_bytes()); + assert!(pool.open_count() <= 2); + } + } + // reader 0 was closed by the later reads; its file is now shorter than when opened + std::fs::write(&paths[0], b"short").unwrap(); + let mut b = [0u8; 5]; + assert!(readers[0].read_at(0, &mut b).is_err()); + drop(readers); + assert_eq!(pool.open_count(), 0, "dropped readers close their files"); + std::fs::remove_dir_all(&dir).unwrap(); + } + /// A reader that counts the bytes read from it. struct Counting(MemReader, std::sync::atomic::AtomicU64); impl ByteReader for Counting { From 1ed57e6d4ac5b89556cf979da502645851a12c5e Mon Sep 17 00:00:00 2001 From: ReReddo <249398616+ReReddo@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:08:38 +0300 Subject: [PATCH 064/144] timeline, text editing, program monitor: clicking empty space deselects (#683) Deselecting by clicking somewhere empty only worked in some spots. - Timeline: a click on no track at all (below the tracks, between the video and audio tracks) did nothing; it now deselects clips, transitions and edit points, as a click on an empty stretch of a track does, and a drag from there draws a box selection. - Text editing: a text layer being edited took the keyboard back on every frame and read every key, so after clicking another panel, typing there (the Effects search, say) went into the text (also #227). Moving to another panel now ends the edit; Properties and Essential Graphics, which style the text, keep it. While another field has the keyboard the keys are its own, and the text takes the keyboard back only when nothing has it. Only a move counts, so an edit an agent starts with another panel focused goes on. - Program monitor: a click around the picture, or on the picture's empty space, ends the text edit and deselects the graphic layers and the mask (graphics::deselect_on_monitor); before, only the picture's empty space ended an edit or deselected layers, and a mask stayed selected. The transform box (#639) follows in its own change once that lands. Tests: deselect_ui (3: empty Timeline space deselects and starts a box; clicking another panel ends a text edit while Properties keeps it; empty monitor space deselects a layer and a mask). --- crates/ui-egui/src/panels/graphics.rs | 35 +++++- crates/ui-egui/src/panels/monitor.rs | 5 + crates/ui-egui/src/panels/timeline.rs | 6 +- crates/ui-egui/tests/deselect_ui.rs | 154 ++++++++++++++++++++++++++ 4 files changed, 194 insertions(+), 6 deletions(-) create mode 100644 crates/ui-egui/tests/deselect_ui.rs diff --git a/crates/ui-egui/src/panels/graphics.rs b/crates/ui-egui/src/panels/graphics.rs index 09aead4f..3f38c565 100644 --- a/crates/ui-egui/src/panels/graphics.rs +++ b/crates/ui-egui/src/panels/graphics.rs @@ -386,6 +386,19 @@ const TEXT_EDIT_ID: &str = "gfx-text-edit"; fn end_edit(app: &mut FilmcraftApp, ui: &egui::Ui) { app.ui.gfx_edit = None; ui.memory_mut(|m| m.surrender_focus(egui::Id::new(TEXT_EDIT_ID))); + ui.data_mut(|d| d.remove::(egui::Id::new(TEXT_EDIT_ID).with("panel"))); +} + +/// A click on the Program monitor's empty space (the picture's, or around it): end the text edit +/// and let go of the selected graphic layers and mask (#683). +pub(crate) fn deselect_on_monitor(app: &mut FilmcraftApp, ui: &egui::Ui) { + end_edit(app, ui); + app.session.state.graphic_layers.clear(); + if app.session.state.selected_mask.is_some() + && let Err(e) = app.session.execute("masks.select", json!({"none": true})) + { + app.ui.status = e.to_string(); + } } /// Draw the overlay and handle graphics tools on the Program monitor picture `pic`. @@ -678,10 +691,8 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra end_edit(app, ui); } actions.push(("graphics.selectLayer".into(), json!({"clip": v.clip.0, "layers": [v.layer]}))); - } else if app.ui.gfx_edit.is_some() { - end_edit(app, ui); - } else if sel_clip.is_some() && !sel_layers.is_empty() { - app.session.state.graphic_layers.clear(); + } else { + deselect_on_monitor(app, ui); } } Tool::Pen => { @@ -806,10 +817,26 @@ fn start_edit_at(app: &mut FilmcraftApp, ui: &egui::Ui, v: &LayerView, p: Pos2, /// Keyboard handling while a text layer is being edited. fn text_edit(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &LayerView, mut ed: GfxEdit, resp: &egui::Response, actions: &mut Vec<(String, Value)>) { let _ = resp; + // moving to another panel ends the edit; Properties and Essential Graphics style the text + // being edited, so working there keeps it (#683). Only a move counts: an edit started with + // another panel focused (an agent's `ui.set`) goes on until the focus moves. + use crate::dock::PanelKind; + let panel_id = egui::Id::new(TEXT_EDIT_ID).with("panel"); + let before = ui.data(|d| d.get_temp::(panel_id)); + ui.data_mut(|d| d.insert_temp(panel_id, app.ui.focused)); + if before.is_some_and(|b| b != app.ui.focused) && !matches!(app.ui.focused, PanelKind::Program | PanelKind::Properties | PanelKind::EssentialGraphics) { + end_edit(app, ui); + return; + } let eid = egui::Id::new(TEXT_EDIT_ID); // a real (non-clickable) widget holds keyboard focus so shortcuts pause while typing let fr = ui.interact(Rect::from_points(&v.quad()), eid, Sense::focusable_noninteractive()); fr.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, true, "Text layer")); + // while another field has the keyboard (a value typed in Properties) the keys are its own; + // the text takes the keyboard back once nothing has it (#683) + if ui.memory(|m| m.focused().is_some_and(|f| f != eid)) { + return; + } ui.memory_mut(|m| { m.request_focus(eid); m.set_focus_lock_filter(eid, egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true }); diff --git a/crates/ui-egui/src/panels/monitor.rs b/crates/ui-egui/src/panels/monitor.rs index 11f4bc2d..8476a32b 100644 --- a/crates/ui-egui/src/panels/monitor.rs +++ b/crates/ui-egui/src/panels/monitor.rs @@ -279,6 +279,11 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) let c = if app.playback.meter.counts().1 > 0 { t.render_yellow } else { t.render_green }; ui.painter().circle_filled(pos2(video_area.min.x + 10.0, video_area.min.y + 10.0), 4.0, c); } + // A click on the Program monitor around the picture lets go of what is selected on it (#683; + // in the picture the overlays do that). Under the picture and the overlays, so they come first. + if which == Which::Program && show_picture && ui.interact(video_area, egui::Id::new((prefix, "background")), Sense::click()).clicked() { + crate::panels::graphics::deselect_on_monitor(app, ui); + } // Click/drag in the picture: the Hand tool pans a magnified picture, otherwise focus. let pic_resp = ui.interact(pic, egui::Id::new((prefix, "pic")), if show_picture { Sense::click_and_drag() } else { Sense::hover() }); app.auto.add(&format!("{prefix}.picture"), pic.intersect(video_area), "picture"); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index d5997839..4a99b725 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1996,7 +1996,9 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & } } (_, Grab::Other(Hit::Transition { .. })) => None, - (_, Grab::Other(Hit::Empty { .. })) => { + // empty space: an empty stretch of a track, or no track at all (below the tracks, between + // the video and audio tracks, #683) + (_, Grab::Other(Hit::Empty { .. } | Hit::None)) if layout.content.contains(p) => { if resp.drag_started() { Some(Drag::Marquee { start: p }) } else { @@ -2006,7 +2008,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & None } } - (_, Grab::Other(Hit::None)) => None, + (_, Grab::Other(Hit::Empty { .. } | Hit::None)) => None, }; if let Some(d) = started { if matches!(d, Drag::Scrub) { diff --git a/crates/ui-egui/tests/deselect_ui.rs b/crates/ui-egui/tests/deselect_ui.rs new file mode 100644 index 00000000..66095e65 --- /dev/null +++ b/crates/ui-egui/tests/deselect_ui.rs @@ -0,0 +1,154 @@ +//! Clicking empty space deselects, consistently (#683): the Timeline's empty space below and +//! between the tracks, a text layer being edited when another panel is clicked, and the Program +//! monitor's empty space around the picture (graphic layers, masks). + +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 filmcraft_ui_egui::state::GfxEdit; +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); + let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.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 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"] == id).unwrap_or_else(|| panic!("no element {id}")).clone(); + 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 click_at(&mut self, x: f64, y: f64) { + self.ok("ui.click", json!({"x": x, "y": y})); + self.frames(3); + } + fn app(&mut self) -> &mut FilmcraftApp { + self.harness.state_mut() + } +} + +/// A click on the Timeline below the tracks deselects, as one on an empty stretch of a track does; +/// a drag from there draws a box selection. +#[test] +fn empty_timeline_space_below_the_tracks_deselects_and_starts_a_marquee() { + let mut d = Driver::demo(); + d.ok("ui.set", json!({"focused": "Timeline"})); + let seq = d.exec("sequence.inspect", json!({})); + let clip = seq["video"][0]["items"][0]["clip"].as_u64().unwrap(); + let area = d.rect("timeline.tracks"); + // a point in the track area that is on no track at all: going up from the area's bottom + let x = area[0] + area[2] * 0.5; + let y = (0..60) + .map(|k| area[1] + area[3] - 3.0 - 4.0 * k as f64) + .find(|y| d.ok("ui.timeline.hit", json!({"x": x, "y": y}))["hit"] == "none") + .expect("the track area has space on no track"); + let below = (x, y); + d.exec("timeline.select", json!({"clips": [clip]})); + d.frames(2); + d.click_at(below.0, below.1); + assert!(d.app().session.state.selection.is_empty(), "a click below the tracks deselects"); + // a drag from below the tracks up over the clips selects them + let top = d.rect("timeline.track.V1.locked"); + d.ok("ui.drag", json!({"from": {"x": area[0] + 4.0, "y": below.1}, "to": {"x": area[0] + area[2] * 0.6, "y": top[1] + top[3] / 2.0}, "steps": 8})); + d.frames(3); + assert!(!d.app().session.state.selection.is_empty(), "the box from below the tracks selected clips"); +} + +/// A text layer being edited stops being edited when another panel is clicked (its keys went into +/// the text before, #227); working in Properties, which styles the text, keeps the edit. +#[test] +fn clicking_another_panel_ends_text_editing() { + let mut d = Driver::demo(); + let seq = d.exec("sequence.inspect", json!({})); + let at = seq["playhead"].as_i64().unwrap(); + let clip = d.exec("graphics.newText", json!({"text": "Title", "time": at}))["clip"].as_u64().unwrap(); + d.ok("ui.panel.show", json!({"panel": "Properties"})); + let edit = |d: &mut Driver| { + d.app().ui.gfx_edit = Some(GfxEdit { clip, layer: 0, caret: 0, anchor: 0 }); + d.app().ui.focused = filmcraft_ui_egui::dock::PanelKind::Program; + d.frames(3); + }; + edit(&mut d); + assert!(d.app().ui.gfx_edit.is_some()); + let props = d.rect("panel.Properties"); + d.click_at(props[0] + props[2] / 2.0, props[1] + props[3] - 10.0); + assert!(d.app().ui.gfx_edit.is_some(), "a click in Properties keeps the edit"); + let tl = d.rect("timeline.tracks"); + d.click_at(tl[0] + tl[2] / 2.0, tl[1] + tl[3] - 6.0); + assert!(d.app().ui.gfx_edit.is_none(), "a click in the Timeline ends it"); +} + +/// A click on the Program monitor around the picture lets go of the selected graphic layer and +/// mask; so does a click on the picture's empty space. +#[test] +fn empty_monitor_space_deselects_layers_and_masks() { + let mut d = Driver::demo(); + let pic = d.rect("program.picture"); + let panel = d.rect("panel.Program"); + // the empty space beside or above the picture, a few points away from it + let outside = if pic[0] - panel[0] > 20.0 { (pic[0] - 8.0, pic[1] + pic[3] / 2.0) } else { (pic[0] + pic[2] / 2.0, pic[1] + pic[3] + 8.0) }; + let seq = d.exec("sequence.inspect", json!({})); + let clip = seq["video"][0]["items"][0]["clip"].as_u64().unwrap(); + // a graphic layer + let at = seq["playhead"].as_i64().unwrap(); + let gclip = d.exec("graphics.newText", json!({"text": "Title", "time": at}))["clip"].as_u64().unwrap(); + d.exec("graphics.selectLayer", json!({"clip": gclip, "layers": [0]})); + d.frames(2); + assert!(!d.app().session.state.graphic_layers.is_empty()); + d.click_at(outside.0, outside.1); + assert!(d.app().session.state.graphic_layers.is_empty(), "a click around the picture deselects the layer"); + // a mask: around the picture, and on the picture's empty space + d.exec("timeline.select", json!({"clips": [clip]})); + d.exec("effects.apply", json!({"clips": [clip], "effect": "gaussian_blur"})); + let fx = { + let s = &d.harness.state().session; + let it = s.active_sequence().unwrap().find_item(filmcraft_project::ClipId(clip)).unwrap().1; + it.effects.iter().position(|e| e.effect == "gaussian_blur").unwrap() + }; + d.exec("masks.add", json!({"clip": clip, "effect": fx, "shape": "ellipse"})); + for (x, y) in [outside, (pic[0] + 6.0, pic[1] + 6.0)] { + d.exec("masks.select", json!({"clip": clip, "effect": fx, "mask": 0})); + d.frames(2); + d.click_at(x, y); + assert!(d.app().session.state.selected_mask.is_none(), "a click at ({x}, {y}) deselects the mask"); + } +} From c85d0ac973990c59e7dc6d00038c171ef99e01a0 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 19:08:48 +0200 Subject: [PATCH 065/144] #614: Head, Head and Tail and Continuous video thumbnails in the Timeline Problem: a video clip only ever showed its first frame. Show Video Thumbnails was a plain on/off switch, so a wide clip at high zoom stayed a flat colour after its head. Fix: the wrench menu keeps Show Video Thumbnails and adds Head (the default, as before), Head and Tail and Continuous under it. Continuous tiles aspect-correct frames across the clip on a grid anchored at its head, only where the panel shows them (at most 256 per clip), each frame the source time under the tile's left edge (speed, reverse, frame holds, multicam angles), requested at thumbnail priority through the existing frame cache. A clip too narrow for the tiles falls back to its head. The mode is UI state (`thumbnail_mode`, serde default Head, so saved settings keep working) and `ui.set {timeline: {thumbnails}}` sets it. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/control.rs | 14 ++- 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/panels/timeline.rs | 134 +++++++++++++++++---- crates/ui-egui/src/state.rs | 33 +++++ crates/ui-egui/tests/timeline_scroll_ui.rs | 22 ++++ docs/control-protocol.md | 2 +- 8 files changed, 191 insertions(+), 23 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 16260258..a68bc34a 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -27,7 +27,7 @@ use serde_json::{Value, json}; use crate::FilmcraftApp; use crate::dock::PanelKind; -use crate::state::{Mode, PlaybackRes, Tool}; +use crate::state::{Mode, PlaybackRes, ThumbnailMode, Tool}; /// Prefix marking errors that may resolve after another frame. pub const RETRY: &str = "\u{1}"; @@ -189,6 +189,18 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) if let Some(v) = tl.get("audioTrackHeight").and_then(Value::as_f64) { app.ui.timeline.audio_track_h = v as f32; } + // Show Video Thumbnails: "off" or a mode ("head", "headAndTail", "continuous") + if let Some(m) = tl.get("thumbnails").and_then(Value::as_str) { + if m.eq_ignore_ascii_case("off") { + app.ui.timeline.show_thumbnails = false; + } else { + let Some(mode) = ThumbnailMode::from_name(m) else { + return err(format!("unknown thumbnail mode `{m}` (off, head, headAndTail, continuous)")); + }; + app.ui.timeline.show_thumbnails = true; + app.ui.timeline.thumbnail_mode = mode; + } + } } if let Some(q) = s("effectsSearch") { app.ui.effects_search = q.to_string(); diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 08f899ec..1c3284f3 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1215,6 +1215,9 @@ Caption track options Opciones de pista de subtítulos Timeline Display Settings Ajustes de visualización de la línea de tiempo Show Video Thumbnails Mostrar miniaturas de video + Head Cabeza + Head and Tail Cabeza y cola + Continuous Continuas Show Audio Waveform Mostrar forma de onda de audio Expand All Tracks Expandir todas las pistas Minimize All Tracks Minimizar todas las pistas diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 8dbf4419..1afb0d57 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1215,6 +1215,9 @@ Caption track options キャプショントラックのオプション Timeline Display Settings タイムライン表示設定 Show Video Thumbnails ビデオのサムネイルを表示 + Head 先頭 + Head and Tail 先頭と末尾 + Continuous 連続 Show Audio Waveform オーディオ波形を表示 Expand All Tracks すべてのトラックを展開 Minimize All Tracks すべてのトラックを最小化 diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index 731067f9..e6d56c7e 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -1208,6 +1208,9 @@ Caption track options 字幕轨道选项 Timeline Display Settings 时间轴显示设置 Show Video Thumbnails 显示视频缩略图 + Head 首帧 + Head and Tail 首尾帧 + Continuous 连续 Show Audio Waveform 显示音频波形 Expand All Tracks 展开所有轨道 Minimize All Tracks 最小化所有轨道 diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index d5997839..9aa3d98c 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -19,7 +19,7 @@ use serde_json::{Value, json}; use crate::FilmcraftApp; use crate::icons::{self, Icon}; -use crate::state::{TimelineView, Tool}; +use crate::state::{ThumbnailMode, TimelineView, Tool}; use crate::theme::Tokens; use super::timeline_hit::{EdgeKind, Grab, edge_geometry, grab_at}; @@ -604,6 +604,36 @@ fn in_range(app: &FilmcraftApp, it: &TrackItem) -> bool { it.start < b && it.end() > a } +/// Most thumbnails one clip draws: a long clip far zoomed in only shows the visible ones anyway. +const MAX_THUMBNAIL_TILES: usize = 256; + +/// The video thumbnails of a clip whose picture area spans `x0..x1` (points), for frames `tile_w` +/// wide: the left edge of each tile and the offset into the clip (`dur` long, frames of `frame`) +/// whose picture it shows. Only tiles that meet `visible` (the panel's x range) are listed. +/// Continuous tiles sit on a grid anchored at the clip's head, so scrolling only reveals new tiles +/// and the others keep their frames. A clip too narrow for more than one tile shows its head. +pub fn thumbnail_tiles(mode: ThumbnailMode, x0: f32, x1: f32, tile_w: f32, visible: (f32, f32), dur: Tick, frame: Tick) -> Vec<(f32, Tick)> { + let width = x1 - x0; + if !(x0.is_finite() && width.is_finite() && width > 0.0 && tile_w.is_finite() && tile_w >= 1.0) { + return Vec::new(); + } + let last = (dur - frame).max(Tick::ZERO); + let offset_at = |x: f32| Tick(((x - x0) as f64 / width as f64 * dur.0 as f64).round() as i64).clamp(Tick::ZERO, last); + let shown = |x: f32| x < visible.1 && x + tile_w > visible.0; + let head = (x0, Tick::ZERO); + let tiles = match mode { + ThumbnailMode::Head => vec![head], + ThumbnailMode::HeadAndTail if width >= 2.0 * tile_w => vec![head, (x1 - tile_w, last)], + ThumbnailMode::HeadAndTail => vec![head], + ThumbnailMode::Continuous => { + let first = ((visible.0 - x0).max(0.0) / tile_w).floor() as usize; + let end = ((visible.1.min(x1) - x0) / tile_w).ceil().max(1.0) as usize; + (first..end).take(MAX_THUMBNAIL_TILES).map(|k| x0 + k as f32 * tile_w).map(|x| (x, offset_at(x))).collect() + } + }; + tiles.into_iter().filter(|(x, _)| shown(*x)).collect() +} + #[allow(clippy::too_many_arguments)] fn draw_clip( app: &mut FilmcraftApp, @@ -632,7 +662,8 @@ fn draw_clip( p.line_segment([pos2(body.min.x, body.min.y + 0.5), pos2(body.max.x, body.min.y + 0.5)], Stroke::new(1.0, lighten(fill, 0.15))); let name_h = 16.0; let w = body.width(); - // head thumbnail (video): left-aligned, aspect-correct, below the name band + // video thumbnails, aspect-correct, below the name band: the head frame, head and tail, or + // frames side by side across the clip (Show Video Thumbnails in the wrench menu) if kind == TrackKind::Video && app.ui.timeline.show_thumbnails && body.height() > 28.0 && w > 20.0 && it.enabled { let th = Rect::from_min_max(pos2(body.min.x + 1.0, body.min.y + name_h), pos2(body.max.x - 1.0, body.max.y - 1.0)); let aspect = app.session.project.item(it.item).and_then(|pi| match &pi.kind { @@ -640,24 +671,28 @@ fn draw_clip( filmcraft_project::ItemKind::Sequence(s) => Some(s.settings.width as f32 / s.settings.height as f32), _ => None, }); - if let Some(aspect) = aspect { - let tw = (th.height() * aspect).min(th.width()); - let mt = rate.snap(it.source_in); - // a multi-camera clip shows its angle's clip - let (thumb_item, mt) = app - .session - .project - .sequence(it.item) - .and_then(|q| { - let ti = q.angle_video_track_index(it.multicam_angle(q)?)?; - let inner = q.video_tracks[ti].item_at(mt)?; - Some((inner.item, inner.source_time_at(mt))) - }) - .unwrap_or((it.item, mt)); - if let Some((tex, _)) = app.thumbnail(ctx, thumb_item, mt, 160) { - let r = Rect::from_min_size(th.min, vec2(tw, th.height())); - let cp = p.with_clip_rect(th.intersect(p.clip_rect())); - cp.image(tex, r, Rect::from_min_max(pos2(0.0, 0.0), pos2((tw / (th.height() * aspect)).min(1.0), 1.0)), Color32::WHITE); + if let Some(aspect) = aspect.filter(|a| a.is_finite() && *a > 0.0) { + let tw = th.height() * aspect; + let cp = p.with_clip_rect(th.intersect(p.clip_rect())); + let visible = (cp.clip_rect().min.x, cp.clip_rect().max.x); + let mode = app.ui.timeline.thumbnail_mode; + for (x, offset) in thumbnail_tiles(mode, th.min.x, th.max.x, tw, visible, it.duration, rate.frame_duration()) { + let mt = rate.snap(it.source_time_at(it.start + offset)); + // a multi-camera clip shows its angle's clip + let (thumb_item, mt) = app + .session + .project + .sequence(it.item) + .and_then(|q| { + let ti = q.angle_video_track_index(it.multicam_angle(q)?)?; + let inner = q.video_tracks.get(ti)?.item_at(mt)?; + Some((inner.item, inner.source_time_at(mt))) + }) + .unwrap_or((it.item, mt)); + if let Some((tex, _)) = app.thumbnail(ctx, thumb_item, mt, 160) { + let r = Rect::from_min_size(pos2(x, th.min.y), vec2(tw, th.height())); + cp.image(tex, r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } } } } @@ -1179,7 +1214,20 @@ fn draw_top(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq: &Sequenc } if key == "settings" { egui::Popup::menu(&resp).show(|ui| { - ui.checkbox(&mut app.ui.timeline.show_thumbnails, tl!("Show Video Thumbnails")); + let c = ui.checkbox(&mut app.ui.timeline.show_thumbnails, tl!("Show Video Thumbnails")); + app.auto.add("timeline.settings.showThumbnails", c.rect, "Show Video Thumbnails"); + let on = app.ui.timeline.show_thumbnails; + ui.indent("timeline.settings.thumbnails", |ui| { + for (mode, label) in + [(ThumbnailMode::Head, tl!("Head")), (ThumbnailMode::HeadAndTail, tl!("Head and Tail")), (ThumbnailMode::Continuous, tl!("Continuous"))] + { + let r = ui.add_enabled(on, egui::RadioButton::new(app.ui.timeline.thumbnail_mode == mode, label)); + app.auto.add(&format!("timeline.settings.thumbnails.{}", mode.name()), r.rect, label); + if r.clicked() { + app.ui.timeline.thumbnail_mode = mode; + } + } + }); ui.checkbox(&mut app.ui.timeline.show_waveforms, tl!("Show Audio Waveform")); ui.separator(); let mut te = app.session.state.show_through_edits; @@ -2524,3 +2572,47 @@ mod chain_starts_tests { assert_eq!(chain_starts(Tick(5), &[Tick(-3), Tick(4)]), vec![Tick(5), Tick(5)]); } } + +#[cfg(test)] +mod thumbnail_tiles_tests { + use super::*; + + const S: Tick = Tick(TICKS_PER_SECOND); + const FRAME: Tick = Tick(TICKS_PER_SECOND / 25); + + #[test] + fn continuous_thumbnails_tile_the_visible_part_of_the_clip() { + // a 10 s clip 1000 pt wide, 80 pt tiles, the panel showing x 250..650 + let tiles = thumbnail_tiles(ThumbnailMode::Continuous, 0.0, 1000.0, 80.0, (250.0, 650.0), Tick(10 * S.0), FRAME); + let xs: Vec = tiles.iter().map(|t| t.0).collect(); + assert_eq!(xs, [240.0, 320.0, 400.0, 480.0, 560.0, 640.0]); + // each tile shows the clip time under its left edge (100 pt per second) + assert_eq!(tiles[0].1, Tick(24 * S.0 / 10)); + assert_eq!(tiles[5].1, Tick(64 * S.0 / 10)); + // scrolling 30 pt moves the tiles with the clip: the ones still shown keep their frames + let scrolled = thumbnail_tiles(ThumbnailMode::Continuous, -30.0, 970.0, 80.0, (250.0, 650.0), Tick(10 * S.0), FRAME); + assert_eq!(scrolled.first(), Some(&(210.0, tiles[0].1))); + assert!(scrolled.iter().all(|(x, t)| tiles.iter().any(|(x0, t0)| *x0 - 30.0 == *x && t0 == t))); + // the last tile never shows a time past the clip's last frame + let end = thumbnail_tiles(ThumbnailMode::Continuous, 0.0, 1000.0, 80.0, (0.0, 2000.0), Tick(10 * S.0), FRAME); + assert_eq!(end.len(), 13); + assert!(end.iter().all(|(_, t)| *t <= Tick(10 * S.0) - FRAME)); + // a clip far zoomed in asks for a bounded number of frames + let huge = thumbnail_tiles(ThumbnailMode::Continuous, -1.0e9, 1.0e9, 1.0, (-2.0e9, 2.0e9), Tick(10 * S.0), FRAME); + assert_eq!(huge.len(), MAX_THUMBNAIL_TILES); + } + + #[test] + fn head_and_tail_thumbnails_need_room_for_two_frames() { + let dur = Tick(4 * S.0); + assert_eq!(thumbnail_tiles(ThumbnailMode::Head, 100.0, 500.0, 80.0, (0.0, 800.0), dur, FRAME), [(100.0, Tick::ZERO)]); + assert_eq!(thumbnail_tiles(ThumbnailMode::HeadAndTail, 100.0, 500.0, 80.0, (0.0, 800.0), dur, FRAME), [(100.0, Tick::ZERO), (420.0, dur - FRAME)]); + // narrower than two tiles: the head only, as in Continuous narrower than one tile + assert_eq!(thumbnail_tiles(ThumbnailMode::HeadAndTail, 100.0, 250.0, 80.0, (0.0, 800.0), dur, FRAME), [(100.0, Tick::ZERO)]); + assert_eq!(thumbnail_tiles(ThumbnailMode::Continuous, 100.0, 150.0, 80.0, (0.0, 800.0), dur, FRAME), [(100.0, Tick::ZERO)]); + // off screen or degenerate: nothing + assert!(thumbnail_tiles(ThumbnailMode::Head, 900.0, 1300.0, 80.0, (0.0, 800.0), dur, FRAME).is_empty()); + assert!(thumbnail_tiles(ThumbnailMode::Continuous, 0.0, 400.0, f32::NAN, (0.0, 800.0), dur, FRAME).is_empty()); + assert!(thumbnail_tiles(ThumbnailMode::Continuous, 0.0, 0.0, 80.0, (0.0, 800.0), dur, FRAME).is_empty()); + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index aacea877..93133616 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -190,6 +190,9 @@ pub struct TimelineView { pub audio_track_h: f32, pub header_w: f32, pub show_thumbnails: bool, + /// Which frames a video clip shows while `show_thumbnails` is on (the wrench menu). + #[serde(default)] + pub thumbnail_mode: ThumbnailMode, pub show_waveforms: bool, /// Follow playhead during playback (page scroll). pub follow: bool, @@ -225,6 +228,7 @@ impl Default for TimelineView { audio_track_h: 56.0, header_w: 204.0, show_thumbnails: true, + thumbnail_mode: ThumbnailMode::Head, show_waveforms: true, follow: true, fit_pending: true, @@ -234,6 +238,35 @@ impl Default for TimelineView { } } +/// Video thumbnails on timeline clips (Timeline wrench menu ▸ Show Video Thumbnails). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum ThumbnailMode { + /// The clip's first frame at its head. + #[default] + Head, + /// The first frame at the head and the last one at the tail. + HeadAndTail, + /// Frames side by side across the whole clip. + Continuous, +} + +impl ThumbnailMode { + pub const ALL: [ThumbnailMode; 3] = [ThumbnailMode::Head, ThumbnailMode::HeadAndTail, ThumbnailMode::Continuous]; + + /// The name used by automation ids and `ui.set` (`timeline.thumbnails`). + pub fn name(self) -> &'static str { + match self { + ThumbnailMode::Head => "head", + ThumbnailMode::HeadAndTail => "headAndTail", + ThumbnailMode::Continuous => "continuous", + } + } + + pub fn from_name(name: &str) -> Option { + Self::ALL.into_iter().find(|m| m.name().eq_ignore_ascii_case(name)) + } +} + /// Monitor display mode (View ▸ Display Mode / the monitor's wrench menu). Multi-Camera is the /// separate `MonitorView::multicam` switch. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/ui-egui/tests/timeline_scroll_ui.rs b/crates/ui-egui/tests/timeline_scroll_ui.rs index ef23d50f..af20a815 100644 --- a/crates/ui-egui/tests/timeline_scroll_ui.rs +++ b/crates/ui-egui/tests/timeline_scroll_ui.rs @@ -263,3 +263,25 @@ fn the_scroll_bar_thumb_follows_the_pointer_and_reaches_both_ends() { let (scroll, pps, ..) = d.view(); assert!(pps < 50.0 && (scroll - at).abs() < 1e-6, "{pps} {scroll}"); } + +/// Show Video Thumbnails ▸ Continuous asks for frames across each video clip, not only its head; +/// `ui.set` switches the mode (and Off) and refuses an unknown one. +#[test] +fn continuous_video_thumbnails_ask_for_frames_across_each_clip() { + let mut d = Driver::demo(); + d.ok("ui.set", json!({"timeline": {"thumbnails": "head", "pps": 50.0, "scroll": 0.0}})); + d.view(); + d.frames(10); + let head = d.app().frames.stats().request_misses; + d.ok("ui.set", json!({"timeline": {"thumbnails": "continuous"}})); + d.frames(3); + let continuous = d.app().frames.stats().request_misses; + assert!(continuous >= head + 5, "continuous thumbnails queued {} more frames", continuous - head); + let tl = d.ok("ui.inspect", none())["ui"]["timeline"].clone(); + assert_eq!((tl["show_thumbnails"].clone(), tl["thumbnail_mode"].clone()), (json!(true), json!("Continuous"))); + d.ok("ui.set", json!({"timeline": {"thumbnails": "off"}})); + assert!(!d.app().ui.timeline.show_thumbnails); + let v = d.call("ui.set", json!({"timeline": {"thumbnails": "filmstrip"}})); + assert_eq!(v["ok"], json!(false), "{v}"); + assert!(!d.app().ui.timeline.show_thumbnails); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 9cf58cf6..f13e0e7c 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -29,7 +29,7 @@ Methods (handlers in `crates/ui-egui/src/control.rs`): | `engine.commands` / `ui.menu.list` | – | command registry / menu tree | | `ui.inspect` | – | UI state (tool, workspace, dock, timeline view, playback, fps…) | | `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:{…}}` | `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.set` | `{tool, workspace, mode, theme, focused, playbackRes, timeline:{pps,scroll,fit,thumbnails}, program:{…}, source:{…}, clipDialog:{param: value}, menuDialog:{param: value}, panels:{scopes, timecode, reference, events, progress}, export:{…}}` | `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)); `timeline.thumbnails` is `off`, `head`, `headAndTail` or `continuous` (the wrench menu's Show Video Thumbnails); `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 | From 86d113e557170116deb59c60afeb76b68369438c Mon Sep 17 00:00:00 2001 From: Oskar Date: Sat, 10 Oct 2026 19:15:30 +0200 Subject: [PATCH 066/144] edit: Close Gap refuses a locked track Closing a gap on a locked track skipped that track but still moved the sync-locked ones, pulling them out of sync with it. It is now refused with the usual "locked" error. --- crates/edit/src/lib.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 2f5a8382..3bd1b370 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -448,6 +448,10 @@ pub fn ripple_delete_items(seq: &mut Sequence, items: &[ClipId]) -> Result Result<()> { let tr = seq.track(track).ok_or(EditError::NoTrack(track))?; + if tr.locked { + // the other tracks would move while this one stayed put + return Err(EditError::Locked); + } if tr.item_at(t).is_some() { return Err(EditError::Nothing); } From c8f85af5a1c56aa6646eb4d5ff31466414b6c6ae Mon Sep 17 00:00:00 2001 From: Oskar Date: Sat, 10 Oct 2026 19:15:30 +0200 Subject: [PATCH 067/144] #648, #668: select a gap and close it with Delete or Ripple Delete timeline.selectGap selects the empty time on one track between two clips (or before the first one). Delete, Backspace (edit.clear) and Ripple Delete then close it as one "Ripple Delete" undo step, as in Premiere Pro. The selection lives in the editor state (gapSelection in sequence.inspect), is replaced by a clip, transition or edit point selection, and is dropped by an edit that changes the gap. sequence.closeGap shares the same code. --- crates/engine/src/commands.rs | 98 ++++++++++--- crates/engine/src/gap_selection_tests.rs | 174 +++++++++++++++++++++++ crates/engine/src/lib.rs | 30 +++- crates/engine/src/trim.rs | 2 + 4 files changed, 284 insertions(+), 20 deletions(-) create mode 100644 crates/engine/src/gap_selection_tests.rs diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 0b3a9f8a..390cc236 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -82,16 +82,20 @@ pub(crate) fn has_selection(s: &Session) -> std::result::Result<(), String> { /// Clips or captions selected (Clear / Ripple Delete work on either). fn has_any_selection(s: &Session) -> std::result::Result<(), String> { has_seq(s)?; - if s.state.selection.is_empty() && s.state.caption_selection.is_empty() && s.state.transition_selection.is_empty() { + if s.state.selection.is_empty() && s.state.caption_selection.is_empty() && s.state.transition_selection.is_empty() && s.state.gap_selection.is_none() { Err("nothing selected".into()) } else { Ok(()) } } -/// Clips or captions selected (Ripple Delete: a transition has no length of its own to close). +/// Clips, captions or a gap selected (Ripple Delete: a transition has no length of its own to close). fn has_clip_or_caption_selection(s: &Session) -> std::result::Result<(), String> { has_seq(s)?; - if s.state.selection.is_empty() && s.state.caption_selection.is_empty() { Err("nothing selected".into()) } else { Ok(()) } + if s.state.selection.is_empty() && s.state.caption_selection.is_empty() && s.state.gap_selection.is_none() { + Err("nothing selected".into()) + } else { + Ok(()) + } } fn has_source(s: &Session) -> std::result::Result<(), String> { has_seq(s)?; @@ -1218,6 +1222,12 @@ fn build() -> Vec { let ids = s.state.transition_selection.clone(); return remove_transitions(s, &ids, "Clear"); } + // a selected gap closes, as in Premiere Pro (#648, #668) + if s.state.caption_selection.is_empty() + && let Some(g) = s.state.gap_selection + { + return ripple_delete_gap(s, g); + } let caps = s.state.caption_selection.clone(); return crate::captions::delete(s, &caps, false); } @@ -1231,6 +1241,11 @@ fn build() -> Vec { }), cmd!("edit.rippleDelete", "Ripple Delete", ["Edit"], Some("Shift+Delete"), r#"{"clips":[id]?}"#, has_clip_or_caption_selection, |s, p| { if p.get("clips").is_none() && p.get("clip").is_none() && s.state.selection.is_empty() { + if s.state.caption_selection.is_empty() + && let Some(g) = s.state.gap_selection + { + return ripple_delete_gap(s, g); + } let caps = s.state.caption_selection.clone(); return crate::captions::delete(s, &caps, true); } @@ -1250,11 +1265,13 @@ fn build() -> Vec { cmd!("edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{}", has_seq, |s, _| { let all: Vec = s.active_sequence().map(|q| q.all_tracks().flat_map(|t| t.items.iter().map(|i| i.id)).collect()).unwrap_or_default(); s.state.selection = all; + s.state.gap_selection = None; Ok(json!({"selected": s.state.selection.len()})) }), cmd!("edit.deselectAll", "Deselect All", ["Edit"], Some("Cmd+Shift+A"), "{}", always, |s, _| { s.state.selection.clear(); s.state.caption_selection.clear(); + s.state.gap_selection = None; Ok(Value::Null) }), cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+Shift+/"), "{}", has_project_selection, |s, _| { @@ -1648,21 +1665,7 @@ fn build() -> Vec { cmd!("sequence.closeGap", "Close Gap", ["Sequence"], None, r#"{"track":"V1"|id,"time":ticks}"#, has_seq, |s, p| { let tr = track_p(s, p, "track", "sequence.closeGap")?.ok_or_else(|| bad("sequence.closeGap", "need `track`"))?; let t = time_p(s, p, "").unwrap_or(s.playhead()); - s.edit_sequence("Ripple Delete", |q, _, st| { - let before: Vec<(ClipId, Tick)> = q.track(tr).map(|x| x.items.iter().map(|i| (i.id, i.start)).collect()).unwrap_or_default(); - edit::close_gap(q, tr, t)?; - if st.ripple_sequence_markers { - // the gap ended where the first clip that moved started - let moved = before - .iter() - .filter_map(|(id, old)| q.find_item(*id).filter(|(_, i)| i.start != *old).map(|(_, i)| (*old, *old - i.start))) - .min_by_key(|m| m.0); - if let Some((end, len)) = moved { - crate::sequence_tools::ripple_markers(&mut q.markers, end, -len); - } - } - Ok(()) - })?; + close_gap(s, tr, t)?; Ok(Value::Null) }), cmd!("sequence.snap", "Snap in Timeline", ["Sequence"], Some("S"), r#"{"on":bool?}"#, always, |s, p| { @@ -2146,8 +2149,9 @@ fn build() -> Vec { // selecting clips leaves trim mode (Premiere: clip and edit point selections are exclusive) s.state.edit_points.clear(); s.state.trim_shift = Default::default(); - // and a clip selection replaces a transition one + // and a clip selection replaces a transition or gap one s.state.transition_selection.clear(); + s.state.gap_selection = None; if bool_p(p, "toggle").unwrap_or(false) { for c in clips { if let Some(i) = s.state.selection.iter().position(|x| *x == c) { @@ -2168,6 +2172,7 @@ fn build() -> Vec { } Ok(json!({"selection": s.state.selection.iter().map(|c| c.0).collect::>()})) }), + cmd!("timeline.selectGap", "Select Gap", [], None, r#"{"track":"V1"|id,"time":ticks|"frame":n|"seconds":f}"#, has_seq, select_gap), cmd!( "timeline.move", "Move Clips", @@ -3350,6 +3355,7 @@ fn select_transitions(s: &mut Session, ids: &Value, add: bool, toggle: bool) -> } s.state.selection.clear(); s.state.caption_selection.clear(); + s.state.gap_selection = None; s.state.edit_points.clear(); s.state.trim_shift = Default::default(); Ok(json!({"selection": [], "transitionSelection": s.state.transition_selection.iter().map(|t| t.0).collect::>()})) @@ -3366,6 +3372,59 @@ fn remove_transitions(s: &mut Session, ids: &[TransitionId], label: &str) -> Res Ok(json!({"removed": n})) } +/// `timeline.selectGap`: select the gap on `track` at `time` (clicking empty space between +/// clips, #648, #668). It replaces the clip, transition, caption and edit point selections, as +/// in Premiere Pro. Empty time after a track's last clip is not a gap. +fn select_gap(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "timeline.selectGap"; + let track = track_p(s, p, "track", CMD)?.ok_or_else(|| bad(CMD, "need `track`"))?; + let t = time_p(s, p, "").ok_or_else(|| bad(CMD, "need `time`, `frame` or `seconds`"))?; + let q = s.active_sequence().ok_or(EngineError::NoSequence)?; + let tr = q.track(track).ok_or_else(|| bad(CMD, format!("no track {}", track.0)))?; + if tr.locked { + return Err(EngineError::Edit(edit::EditError::Locked)); + } + let range = edit::through::track_gaps(tr).into_iter().find(|g| g.contains(t)).ok_or_else(|| bad(CMD, "there is no gap there"))?; + s.state.gap_selection = Some(crate::GapSelection { track, range }); + s.state.selection.clear(); + s.state.caption_selection.clear(); + s.state.transition_selection.clear(); + s.state.edit_points.clear(); + s.state.trim_shift = Default::default(); + Ok(json!({"track": track.0, "start": range.start.0, "end": range.end().0})) +} + +/// Close the selected gap (Delete, Backspace or Ripple Delete on a gap). +fn ripple_delete_gap(s: &mut Session, g: crate::GapSelection) -> Result { + let still = s.active_sequence().and_then(|q| q.track(g.track)).is_some_and(|tr| edit::through::track_gaps(tr).contains(&g.range)); + if !still { + s.state.gap_selection = None; + return Err(EngineError::Other("the selected gap is no longer there".into())); + } + close_gap(s, g.track, g.range.start)?; + s.state.gap_selection = None; + Ok(json!({"closed": g.range.duration.0})) +} + +/// `sequence.closeGap`: ripple the gap on `track` at `t` closed (one undo step, "Ripple Delete"). +fn close_gap(s: &mut Session, tr: TrackId, t: Tick) -> Result<()> { + s.edit_sequence("Ripple Delete", |q, _, st| { + let before: Vec<(ClipId, Tick)> = q.track(tr).map(|x| x.items.iter().map(|i| (i.id, i.start)).collect()).unwrap_or_default(); + edit::close_gap(q, tr, t)?; + if st.ripple_sequence_markers { + // the gap ended where the first clip that moved started + let moved = before + .iter() + .filter_map(|(id, old)| q.find_item(*id).filter(|(_, i)| i.start != *old).map(|(_, i)| (*old, *old - i.start))) + .min_by_key(|m| m.0); + if let Some((end, len)) = moved { + crate::sequence_tools::ripple_markers(&mut q.markers, end, -len); + } + } + Ok(()) + }) +} + /// `sequence.setTransition`: edit an applied transition (Effect Controls, the Timeline): its /// settings, Reverse, and where it sits. `duration` / `frames` resize it the way its alignment /// says (Center at Cut and Custom Start: both ends; Start at Cut: the end; End at Cut: the @@ -3588,6 +3647,7 @@ pub fn inspect_sequence(s: &Session, id: ItemId, q: &filmcraft_project::Sequence "audio": q.audio_tracks.iter().map(tr).collect::>(), "selection": s.state.selection.iter().map(|c| c.0).collect::>(), "transitionSelection": s.state.transition_selection.iter().map(|t| t.0).collect::>(), + "gapSelection": s.state.gap_selection.map(|g| json!({"track": g.track.0, "start": g.range.start.0, "end": g.range.end().0})), }) } diff --git a/crates/engine/src/gap_selection_tests.rs b/crates/engine/src/gap_selection_tests.rs new file mode 100644 index 00000000..66f1d2d9 --- /dev/null +++ b/crates/engine/src/gap_selection_tests.rs @@ -0,0 +1,174 @@ +//! Selecting a gap between clips and closing it with Delete / Backspace / Ripple Delete (#648, #668): +//! `timeline.selectGap` selects the empty time on one track, `edit.clear` and `edit.rippleDelete` +//! close it as one "Ripple Delete" undo step. + +use filmcraft_project::ClipId; +use filmcraft_time::Tick; +use serde_json::json; + +use crate::{GapSelection, Session}; + +fn range(s: &Session, c: ClipId) -> (Tick, Tick) { + s.active_sequence().unwrap().find_item(c).map(|(_, i)| (i.start, i.end())).unwrap() +} + +/// Two 48-frame clips (pictures on V1, linked sounds on A1) placed at `frames`. +fn two_clips(s: &mut Session, frames: [i64; 2]) -> [(ClipId, ClipId); 2] { + s.execute("file.openDemoProject", json!({})).unwrap(); + s.execute("file.newSequence", json!({"name": "Gaps", "video": 1, "audio": 1})).unwrap(); + let rate = s.sequence_rate(); + let item = s.project.items.values().find(|i| i.has_video() && i.has_audio() && i.as_media().is_some()).map(|i| i.id).unwrap(); + for f in frames { + s.execute("timeline.place", json!({"item": item.0, "frame": f, "sourceIn": rate.tick_of(48).0, "duration": rate.tick_of(48).0})).unwrap(); + } + s.execute("edit.deselectAll", json!({})).unwrap(); + let q = s.active_sequence().unwrap(); + [0, 1].map(|n| (q.video_tracks[0].items[n].id, q.audio_tracks[0].items[n].id)) +} + +#[test] +fn clicking_between_two_clips_selects_the_gap_and_delete_closes_it() { + let mut s = Session::default(); + let [(picture1, _), (picture2, sound2)] = two_clips(&mut s, [0, 96]); + let rate = s.sequence_rate(); + let f = |n: i64| rate.tick_of(n); + let (f48, f60, f96) = (f(48), f(60), f(96)); + s.execute("timeline.select", json!({"clips": [picture1.0]})).unwrap(); + let before = s.project.clone(); + + let r = s.execute("timeline.selectGap", json!({"track": "V1", "time": f60.0})).unwrap(); + assert_eq!(r, json!({"track": s.active_sequence().unwrap().video_tracks[0].id.0, "start": f48.0, "end": f96.0})); + assert!(s.state.selection.is_empty(), "the gap replaces the clip selection"); + assert!(s.is_enabled("edit.clear") && s.is_enabled("edit.rippleDelete"), "Delete and Ripple Delete apply to the gap"); + + // Delete / Backspace + s.execute("edit.clear", json!({})).unwrap(); + assert_eq!(range(&s, picture2), (f48, f96), "the next clip moves up into the gap"); + assert_eq!(range(&s, sound2), (f48, f96), "and its sound with it"); + assert_eq!(range(&s, picture1).0, Tick::ZERO, "the clip before stays"); + assert!(s.state.gap_selection.is_none(), "the gap is gone"); + s.active_sequence().unwrap().check().unwrap(); + + assert_eq!(s.undo().as_deref(), Some("Ripple Delete"), "one undo step"); + assert_eq!(*s.project, *before); +} + +#[test] +fn ripple_delete_closes_a_selected_gap_too() { + let mut s = Session::default(); + let [_, (picture2, _)] = two_clips(&mut s, [0, 96]); + let rate = s.sequence_rate(); + let f = |n: i64| rate.tick_of(n); + s.execute("timeline.selectGap", json!({"track": "A1", "frame": 50})).unwrap(); + s.execute("edit.rippleDelete", json!({})).unwrap(); + assert_eq!(range(&s, picture2).0, f(48)); +} + +#[test] +fn the_empty_time_before_the_first_clip_is_a_gap() { + let mut s = Session::default(); + let [(picture1, _), (picture2, _)] = two_clips(&mut s, [24, 72]); + let rate = s.sequence_rate(); + let f = |n: i64| rate.tick_of(n); + let r = s.execute("timeline.selectGap", json!({"track": "V1", "frame": 5})).unwrap(); + assert_eq!((r["start"].clone(), r["end"].clone()), (json!(0), json!(f(24).0))); + s.execute("edit.clear", json!({})).unwrap(); + assert_eq!((range(&s, picture1).0, range(&s, picture2).0), (Tick::ZERO, f(48))); +} + +#[test] +fn empty_time_after_the_last_clip_is_not_a_gap() { + let mut s = Session::default(); + two_clips(&mut s, [0, 96]); + assert!(s.execute("timeline.selectGap", json!({"track": "V1", "frame": 400})).is_err()); + assert!(s.execute("timeline.selectGap", json!({"track": "V1", "frame": 10})).is_err(), "inside a clip"); + assert!(s.state.gap_selection.is_none()); + assert!(!s.is_enabled("edit.clear"), "nothing to delete"); +} + +#[test] +fn selecting_a_clip_or_deselecting_all_drops_the_gap() { + let mut s = Session::default(); + let [(picture1, _), _] = two_clips(&mut s, [0, 96]); + s.execute("timeline.selectGap", json!({"track": "V1", "frame": 60})).unwrap(); + s.execute("timeline.select", json!({"clips": [picture1.0]})).unwrap(); + assert!(s.state.gap_selection.is_none()); + s.execute("timeline.selectGap", json!({"track": "V1", "frame": 60})).unwrap(); + s.execute("edit.deselectAll", json!({})).unwrap(); + assert!(s.state.gap_selection.is_none()); +} + +/// An edit that fills the gap leaves no gap selected; Delete then removes nothing it should not. +#[test] +fn an_edit_that_changes_the_gap_drops_it() { + let mut s = Session::default(); + let [(picture1, _), (picture2, _)] = two_clips(&mut s, [0, 96]); + let rate = s.sequence_rate(); + let f = |n: i64| rate.tick_of(n); + s.execute("timeline.selectGap", json!({"track": "V1", "frame": 60})).unwrap(); + s.execute("timeline.trim", json!({"clip": picture1.0, "edge": "out", "mode": "regular", "deltaFrames": 12})).unwrap(); + assert!(s.state.gap_selection.is_none(), "the gap changed"); + assert!(s.execute("edit.clear", json!({})).is_err()); + assert_eq!(range(&s, picture2).0, f(96), "nothing moved"); +} + +/// A stale selection (set from outside, as the control channel can) is refused, not acted on. +#[test] +fn a_stale_gap_is_refused() { + let mut s = Session::default(); + let [_, (picture2, _)] = two_clips(&mut s, [0, 96]); + let rate = s.sequence_rate(); + let f = |n: i64| rate.tick_of(n); + let track = s.active_sequence().unwrap().video_tracks[0].id; + s.state.gap_selection = Some(GapSelection { track, range: filmcraft_time::TimeRange::from_bounds(f(10), f(20)) }); + assert!(s.execute("edit.clear", json!({})).is_err()); + assert!(s.state.gap_selection.is_none()); + assert_eq!(range(&s, picture2).0, f(96)); +} + +#[test] +fn a_gap_on_a_locked_track_cannot_be_selected_or_closed() { + let mut s = Session::default(); + let [_, (picture2, _)] = two_clips(&mut s, [0, 96]); + let rate = s.sequence_rate(); + let f = |n: i64| rate.tick_of(n); + s.execute("timeline.setTrack", json!({"track": "V1", "locked": true})).unwrap(); + assert!(s.execute("timeline.selectGap", json!({"track": "V1", "frame": 60})).is_err()); + // Sequence ▸ Close Gap moved the other tracks while the locked one stayed put + assert!(s.execute("sequence.closeGap", json!({"track": "V1", "frame": 60})).is_err()); + assert_eq!(range(&s, picture2).0, f(96)); + let sound2 = s.active_sequence().unwrap().audio_tracks[0].items[1].id; + assert_eq!(range(&s, sound2).0, f(96), "the unlocked sound stays in sync"); +} + +#[test] +fn hostile_select_gap_parameters_are_errors() { + let mut s = Session::default(); + assert!(s.execute("timeline.selectGap", json!({"track": "V1", "frame": 1})).is_err(), "no sequence"); + two_clips(&mut s, [0, 96]); + for p in [ + json!({}), + json!({"track": "V1"}), + json!({"frame": 60}), + json!({"track": "V99", "frame": 60}), + json!({"track": 999_999, "frame": 60}), + json!({"track": "V1", "time": i64::MAX}), + json!({"track": "V1", "time": i64::MIN}), + json!({"track": "V1", "frame": i64::MAX}), + json!({"track": "V1", "seconds": f64::MAX}), + json!({"track": [], "frame": "x"}), + ] { + let _ = s.execute("timeline.selectGap", p); + } + assert!(s.state.gap_selection.is_none()); +} + +#[test] +fn the_gap_selection_is_part_of_the_ui_state() { + let mut s = Session::default(); + two_clips(&mut s, [0, 96]); + s.execute("timeline.selectGap", json!({"track": "V1", "frame": 60})).unwrap(); + let v = serde_json::to_value(&s.state).unwrap(); + let back: crate::EditorState = serde_json::from_value(v).unwrap(); + assert_eq!(back.gap_selection, s.state.gap_selection); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index eea55b32..0d48e26c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -57,7 +57,7 @@ use std::sync::Arc; use filmcraft_edit::EditCtx; use filmcraft_project::{ClipId, ItemId, Project, Sequence, TrackId, TrackKind}; -use filmcraft_time::{FrameRate, Tick}; +use filmcraft_time::{FrameRate, Tick, TimeRange}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -221,6 +221,13 @@ pub struct Targeting { pub audio_dest: Option, } +/// A gap on one track of the active sequence (see [`EditorState::gap_selection`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct GapSelection { + pub track: TrackId, + pub range: TimeRange, +} + /// Editing state that commands depend on (not project data, but headless-relevant). #[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct EditorState { @@ -266,6 +273,12 @@ pub struct EditorState { /// them. Selecting clips clears it and selecting transitions clears the clip selection. #[serde(default)] pub transition_selection: Vec, + /// The selected gap (#648, #668): empty time on one track, clicked in the Timeline, from the + /// end of a clip (or the sequence start) up to the next clip. Delete, Backspace and Ripple + /// Delete close it. Selecting clips, transitions or edit points clears it, and so does an edit + /// that changes the gap. + #[serde(default)] + pub gap_selection: Option, /// Selected layers (indices among the graphic layers, 0 = back) of the selected graphic clip. #[serde(default)] pub graphic_layers: Vec, @@ -776,6 +789,7 @@ impl Session { self.history.redo.clear(); self.history.merge_key = None; self.state = st; + self.drop_stale_gap(); self.bump(); Ok(r) } @@ -795,6 +809,7 @@ impl Session { self.refuse_self_nesting(&p)?; self.project = Arc::new(p); self.state = st; + self.drop_stale_gap(); self.bump(); Ok(r) } @@ -888,6 +903,17 @@ impl Session { self.state.caption_selection.clear(); } self.state.project_selection.retain(|i| p.item(*i).is_some()); + self.drop_stale_gap(); + } + + /// Forget the selected gap when it is no longer a gap of the active sequence (a clip moved + /// into it or next to it, its track went, another sequence became the active one). + fn drop_stale_gap(&mut self) { + if let Some(g) = self.state.gap_selection + && !self.active_sequence().and_then(|q| q.track(g.track)).is_some_and(|tr| filmcraft_edit::through::track_gaps(tr).contains(&g.range)) + { + self.state.gap_selection = None; + } } fn bump(&mut self) { @@ -1058,6 +1084,8 @@ mod export_tests; #[cfg(test)] mod file_tests; #[cfg(test)] +mod gap_selection_tests; +#[cfg(test)] mod image_sequence_tests; #[cfg(test)] mod interchange_auto_points_tests; diff --git a/crates/engine/src/trim.rs b/crates/engine/src/trim.rs index dda7a4d1..951a873c 100644 --- a/crates/engine/src/trim.rs +++ b/crates/engine/src/trim.rs @@ -104,6 +104,7 @@ pub fn select(s: &mut Session, p: &Value) -> Result { s.state.edit_points = vec![ep]; } s.state.selection.clear(); + s.state.gap_selection = None; reset_shift(s); Ok(json!({"editPoints": s.state.edit_points})) } @@ -155,6 +156,7 @@ pub fn select_nearest(s: &mut Session, p: &Value) -> Result { } s.state.edit_points = points; s.state.selection.clear(); + s.state.gap_selection = None; reset_shift(s); Ok(json!({"editPoints": s.state.edit_points})) } From ca44d854505d3e601670a7982d06e622d7321de8 Mon Sep 17 00:00:00 2001 From: Oskar Date: Sat, 10 Oct 2026 19:15:30 +0200 Subject: [PATCH 068/144] #648, #668: click a gap in the Timeline, Delete closes it Clicking empty time between two clips selects the gap, drawn as a light block across it. Delete or Backspace closes it; right-clicking it opens a menu with Ripple Delete. A cut's edit point menu right-clicked before no longer stands in for the gap's menu. --- crates/ui-egui/src/panels/timeline.rs | 51 +++++++- crates/ui-egui/tests/timeline_gap_ui.rs | 167 ++++++++++++++++++++++++ 2 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/tests/timeline_gap_ui.rs diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index d5997839..ca227606 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -30,6 +30,8 @@ const RULER_H: f32 = 44.0; const SCROLLBAR_H: f32 = 17.0; const DIVIDER_H: f32 = 5.0; const MASTER_H: f32 = 34.0; +/// The selected gap between two clips (#648, #668). +const GAP_FILL: Color32 = Color32::from_gray(224); const SNAP_PX: f32 = 9.0; /// Transient interaction state. @@ -380,6 +382,16 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } let p = painter.with_clip_rect(Rect::from_min_max(pos2(content.min.x, row.min.y), pos2(content.max.x, row.max.y))); let volume_line = r.kind == TrackKind::Audio && !r.lane; + // the selected gap (#648, #668): a light block across the empty time, as in Premiere Pro + if let Some(g) = app.session.state.gap_selection.filter(|g| g.track == r.track) { + let x0 = layout.x_of(g.range.start).max(content.min.x - 1.0); + let x1 = layout.x_of(g.range.end()).min(content.max.x + 1.0); + if x1 > x0 { + let gap = Rect::from_min_max(pos2(x0, r.rect.min.y + 1.0), pos2(x1, r.rect.max.y - 1.0)); + p.rect_filled(gap, 0.0, GAP_FILL); + app.auto.add("timeline.gap", gap.intersect(content), "Selected gap"); + } + } for it in &tr.items { let (start, dur, moved_track) = previews.get(&it.id).copied().unwrap_or((it.start, it.duration, None)); if moved_track.is_some_and(|m| m != r.track) { @@ -1695,6 +1707,19 @@ fn edit_point_menu(app: &mut FilmcraftApp, ctx: &egui::Context, ui: &mut egui::U } } +/// The menu of a right-clicked gap: Ripple Delete closes it. +fn gap_menu(app: &mut FilmcraftApp, ctx: &egui::Context, ui: &mut egui::Ui) { + let label = tl!("Ripple Delete"); + let r = ui.add_enabled(app.session.state.gap_selection.is_some(), egui::Button::new(label)); + app.auto.add("timeline.gapMenu.edit.rippleDelete", r.rect, label); + if r.clicked() { + if let Err(e) = crate::menus::invoke(app, ctx, "edit.rippleDelete", json!({})) { + app.ui.status = e; + } + ui.close(); + } +} + /// The clip menu's Multi-Camera submenu: Enable, Flatten, and the cameras of the selected nested /// sequence clips (greyed out when none of the selected clips is a nested sequence). fn multicam_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, picked: &[&TrackItem]) { @@ -1968,6 +1993,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & if mods.alt { app.session.state.selection = vec![clip]; app.session.state.transition_selection.clear(); + app.session.state.gap_selection = None; } else { let _ = app.session.execute("timeline.select", json!({"clips": [clip.0]})); } @@ -1996,13 +2022,17 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & } } (_, Grab::Other(Hit::Transition { .. })) => None, - (_, Grab::Other(Hit::Empty { .. })) => { + (_, Grab::Other(Hit::Empty { track })) => { if resp.drag_started() { Some(Drag::Marquee { start: p }) } else { app.session.state.selection.clear(); app.session.state.edit_points.clear(); app.session.state.transition_selection.clear(); + // empty time between clips: select the gap, for Delete to close it (#648, #668) + if app.session.execute("timeline.selectGap", json!({"track": track.0, "time": t.0})).is_err() { + app.session.state.gap_selection = None; + } None } } @@ -2199,6 +2229,21 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & let edit_point_menu_id = egui::Id::new("timeline.editPointMenu.target"); // the clip right-clicked, remembered while its menu is open: Unlink leaves only it selected let menu_clip_id = egui::Id::new("timeline.clipMenu.clip"); + // right-clicking empty time between clips selects the gap and opens its menu (#648, #668) + let gap_menu_id = egui::Id::new("timeline.gapMenu.open"); + if resp.secondary_clicked() + && let Some(p) = resp.interact_pointer_pos() + { + let on_gap = match hit(seq, layout, p) { + Hit::Empty { track } => app.session.execute("timeline.selectGap", json!({"track": track.0, "time": layout.tick_at(p.x).0})).is_ok(), + _ => false, + }; + ctx.data_mut(|d| d.insert_temp(gap_menu_id, on_gap)); + if on_gap { + // not the edit point menu of a cut right-clicked before + ctx.data_mut(|d| d.insert_temp::>(edit_point_menu_id, None)); + } + } if resp.secondary_clicked() && let Some(p) = resp.interact_pointer_pos() && let Hit::Clip { clip, edge, track } = hit(seq, layout, p) @@ -2230,6 +2275,10 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & 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); diff --git a/crates/ui-egui/tests/timeline_gap_ui.rs b/crates/ui-egui/tests/timeline_gap_ui.rs new file mode 100644 index 00000000..930f291d --- /dev/null +++ b/crates/ui-egui/tests/timeline_gap_ui.rs @@ -0,0 +1,167 @@ +//! Headless UI test of gaps in the Timeline (#648, #668): clicking empty time between two clips +//! selects the gap (drawn as a light block), and Delete, Backspace or the gap's right-click menu +//! (Ripple Delete) closes it, as in Premiere Pro. The real `FilmcraftApp` under `egui_kittest`, +//! driven over the control channel. + +use std::sync::mpsc::{Sender, channel}; + +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_engine::project::ClipId; +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 { + /// A sequence with two 48-frame clips on V1 (and their sounds on A1) at frames 0 and 96. + fn two_clips() -> (Self, ClipId, ClipId) { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + s.execute("file.newSequence", json!({"name": "Gaps", "video": 1, "audio": 1})).unwrap(); + let rate = s.sequence_rate(); + let item = s.project.items.values().find(|i| i.has_video() && i.has_audio() && i.as_media().is_some()).map(|i| i.id).unwrap(); + for f in [0, 96] { + s.execute("timeline.place", json!({"item": item.0, "frame": f, "sourceIn": rate.tick_of(48).0, "duration": rate.tick_of(48).0})).unwrap(); + } + s.execute("edit.deselectAll", json!({})).unwrap(); + let v1 = &s.active_sequence().unwrap().video_tracks[0]; + let (a, b) = (v1.items[0].id, v1.items[1].id); + 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 mut d = Driver { harness, tx }; + d.frames(4); + (d, a, b) + } + + fn frames(&mut self, n: usize) { + for _ in 0..n { + // the control channel's clicks and keys enter through the input hook + 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 rect(&mut self, id: &str) -> Option<[f64; 4]> { + self.frames(2); + let v = self.ok("ui.elements", json!({"prefix": id})); + let e = v.as_array().unwrap().iter().find(|e| e["id"] == json!(id))?.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]]) + } + + /// Click (`button`: "left" or "right") in the middle of the empty time between `a` and `b`. + fn click_between(&mut self, a: ClipId, b: ClipId, button: &str) { + let ra = self.rect(&format!("timeline.clip.{}", a.0)).expect("the first clip is shown"); + let rb = self.rect(&format!("timeline.clip.{}", b.0)).expect("the second clip is shown"); + let (gap_l, gap_r) = (ra[0] + ra[2], rb[0]); + assert!(gap_r - gap_l > 10.0, "the gap is wide enough to click: {ra:?} {rb:?}"); + self.ok("ui.click", json!({"x": (gap_l + gap_r) / 2.0, "y": ra[1] + ra[3] / 2.0, "button": button})); + self.frames(3); + } + + fn start(&mut self, c: ClipId) -> i64 { + self.harness.state_mut().session.active_sequence().unwrap().find_item(c).map(|(_, i)| i.start.0).unwrap() + } + + fn frame(&mut self, n: i64) -> i64 { + self.harness.state_mut().session.sequence_rate().tick_of(n).0 + } + + fn gap(&mut self) -> Value { + self.ok("engine.execute", json!({"command": "sequence.inspect", "params": {}}))["gapSelection"].clone() + } +} + +#[test] +fn clicking_a_gap_selects_it_and_delete_closes_it() { + let (mut d, a, b) = Driver::two_clips(); + let (f48, f96) = (d.frame(48), d.frame(96)); + d.click_between(a, b, "left"); + let g = d.gap(); + assert_eq!((g["start"].as_i64(), g["end"].as_i64()), (Some(f48), Some(f96)), "the gap is selected: {g}"); + assert!(d.rect("timeline.gap").is_some(), "and drawn"); + d.ok("ui.key", json!({"key": "Delete"})); + d.frames(3); + assert_eq!(d.start(b), f48, "Delete closes the gap"); + assert_eq!(d.start(a), 0, "the clip before stays"); + assert_eq!(d.gap(), Value::Null); + assert!(d.rect("timeline.gap").is_none(), "nothing is drawn any more"); +} + +#[test] +fn backspace_closes_a_selected_gap_too() { + let (mut d, a, b) = Driver::two_clips(); + let f48 = d.frame(48); + d.click_between(a, b, "left"); + d.ok("ui.key", json!({"key": "Backspace"})); + d.frames(3); + assert_eq!(d.start(b), f48); +} + +#[test] +fn right_clicking_a_gap_offers_ripple_delete() { + let (mut d, a, b) = Driver::two_clips(); + let f48 = d.frame(48); + d.click_between(a, b, "right"); + assert!(d.gap().is_object(), "right-clicking selects the gap"); + assert!(d.rect("timeline.gapMenu.edit.rippleDelete").is_some(), "its menu offers Ripple Delete"); + assert!(d.rect("timeline.clipMenu.edit.cut").is_none(), "not the clip menu"); + d.ok("ui.click", json!({"id": "timeline.gapMenu.edit.rippleDelete"})); + d.frames(3); + assert_eq!(d.start(b), f48, "Ripple Delete closes the gap"); +} + +#[test] +fn clicking_a_clip_or_after_the_last_clip_drops_the_gap() { + let (mut d, a, b) = Driver::two_clips(); + let f96 = d.frame(96); + d.click_between(a, b, "left"); + d.ok("ui.click", json!({"id": format!("timeline.clip.{}", a.0)})); + d.frames(3); + assert_eq!(d.gap(), Value::Null, "a clip selection replaces the gap"); + d.click_between(a, b, "left"); + // empty time after the last clip is not a gap + let rb = d.rect(&format!("timeline.clip.{}", b.0)).unwrap(); + d.ok("ui.click", json!({"x": rb[0] + rb[2] + 30.0, "y": rb[1] + rb[3] / 2.0})); + d.frames(3); + assert_eq!(d.gap(), Value::Null); + d.ok("ui.key", json!({"key": "Delete"})); + d.frames(3); + assert_eq!(d.start(b), f96, "Delete with nothing selected moves nothing"); +} + +/// A cut's edit point menu, right-clicked before, does not stand in for the gap's menu. +#[test] +fn the_gap_menu_follows_an_edit_point_menu() { + let (mut d, a, b) = Driver::two_clips(); + d.ok("ui.click", json!({"id": format!("timeline.clip.{}", b.0), "fx": 0.0, "button": "right"})); + d.frames(3); + assert!(d.rect("timeline.editPointMenu.rippleIn").is_some(), "the edge's edit point menu"); + d.ok("ui.key", json!({"key": "Escape"})); + d.frames(3); + d.click_between(a, b, "right"); + assert!(d.rect("timeline.gapMenu.edit.rippleDelete").is_some(), "the gap's menu"); + assert!(d.rect("timeline.editPointMenu.rippleIn").is_none(), "not the edit point menu"); +} From 3feadebbc445cd98375aabe679eec926dbeda846 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 19:16:40 +0200 Subject: [PATCH 069/144] #313: J/K/L shuttle the Source monitor both ways at up to 8x Problem: with the Source monitor focused, L only ever played at normal speed, and J and the slow shuttle commands returned "Source playback currently supports normal forward speed": the Source clock always ran at 1x. Fix: the Source clock runs at a signed speed. L/J play forward/backward and pressing the same key again doubles the speed up to 8x; Shuttle Slow Right/Left play at 1/4 speed. As in the Program monitor, speeds other than normal forward play without sound, and backward playback stops on the first frame. docs/monitors.md updated. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/menus.rs | 15 +++---- crates/ui-egui/src/source_playback.rs | 47 ++++++++++++++++++---- crates/ui-egui/tests/source_playback_ui.rs | 34 ++++++++++++++++ docs/monitors.md | 15 ++++--- 4 files changed, 91 insertions(+), 20 deletions(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9dff12d3..2a0c8eb5 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -287,7 +287,8 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: } match id { "playback.slowForward" | "playback.slowReverse" if targets_source(app, ¶ms) => { - return Err("Source playback currently supports normal forward speed".into()); + app.shuttle_source(if id == "playback.slowForward" { 0.25 } else { -0.25 })?; + return Ok(json!({"playing": app.source_playback.clock.playing, "speed": app.source_playback.clock.speed})); } "playback.slowForward" | "playback.slowReverse" => { app.play(if id == "playback.slowForward" { 0.25 } else { -0.25 }); @@ -315,12 +316,12 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: app.toggle_play(1.0); return Ok(json!({"playing": app.playback.playing})); } - "playback.forward" if targets_source(app, ¶ms) => { - app.play_source()?; - return Ok(json!({"playing": app.source_playback.clock.playing, "speed": 1.0})); - } - "playback.reverse" if targets_source(app, ¶ms) => { - return Err("Source playback currently supports normal forward speed; use Play/Space or frame stepping".into()); + // L / J: pressing again in the same direction doubles the speed, up to 8× + "playback.forward" | "playback.reverse" if targets_source(app, ¶ms) => { + let (clock, dir) = (&app.source_playback.clock, if id == "playback.forward" { 1.0 } else { -1.0 }); + let speed = if clock.playing && clock.speed * dir > 0.0 { (clock.speed * 2.0).clamp(-8.0, 8.0) } else { dir }; + app.shuttle_source(speed)?; + return Ok(json!({"playing": app.source_playback.clock.playing, "speed": app.source_playback.clock.speed})); } "playhead.stepBack" | "playhead.stepForward" | "playhead.stepBack5" | "playhead.stepForward5" if targets_source(app, ¶ms) => { app.stop_source(); diff --git a/crates/ui-egui/src/source_playback.rs b/crates/ui-egui/src/source_playback.rs index 494f9d08..7cdb7c21 100644 --- a/crates/ui-egui/src/source_playback.rs +++ b/crates/ui-egui/src/source_playback.rs @@ -30,7 +30,16 @@ impl FilmcraftApp { } pub fn play_source(&mut self) -> Result<(), String> { - self.start_source_playback(None, None) + self.start_source_playback(None, None, 1.0) + } + + /// J / K / L in the Source monitor: play at `speed` × real time (negative = backward) from the + /// Source playhead. Sound plays at normal forward speed only, as in the Program monitor. + pub fn shuttle_source(&mut self, speed: f64) -> Result<(), String> { + if !speed.is_finite() || speed == 0.0 { + return Err("Source shuttle speed must be a non-zero number".into()); + } + self.start_source_playback(None, None, speed.clamp(-8.0, 8.0)) } pub fn play_source_range(&mut self, from_playhead: bool, preroll: bool) -> Result<(), String> { @@ -53,10 +62,10 @@ impl FilmcraftApp { if start >= stop { return Err("Source playhead is at or beyond the Out point".into()); } - self.start_source_playback(Some(start), Some(stop)) + self.start_source_playback(Some(start), Some(stop), 1.0) } - fn start_source_playback(&mut self, start: Option, stop: Option) -> Result<(), String> { + fn start_source_playback(&mut self, start: Option, stop: Option, speed: f64) -> Result<(), String> { let item = self.session.state.source_item.ok_or("Open a media file in the Source monitor first")?; let view = source_view(&self.session, item).ok_or("Source item is unavailable")?; let media = self.session.project.item(view.media).and_then(|i| i.as_media()).ok_or("Source playback currently supports media files and subclips")?; @@ -76,7 +85,8 @@ impl FilmcraftApp { self.stop(); self.stop_source(); let mut time = start.unwrap_or(self.session.state.source_playhead); - if start.is_none() && (time < first || time >= view.rate.snap(Tick(end.0.saturating_sub(view.rate.frame_duration().0))).max(first)) { + // forward from the end (or before a looped range) starts over; backward runs from where it is + if start.is_none() && speed > 0.0 && (time < first || time >= view.rate.snap(Tick(end.0.saturating_sub(view.rate.frame_duration().0))).max(first)) { time = first; } self.session.execute("source.setPlayhead", json!({"time": time.0})).map_err(|e| e.to_string())?; @@ -84,8 +94,8 @@ impl FilmcraftApp { self.source_playback.range_start = start; self.source_playback.item = Some(item); self.source_playback.shown = time; - self.source_playback.clock = Playback { playing: true, speed: 1.0, anchor_time: -1.0, anchor_tick: time, looping, stop_at: stop, ..Default::default() }; - self.source_playback.clock.meter.start(1.0); + self.source_playback.clock = Playback { playing: true, speed, anchor_time: -1.0, anchor_tick: time, looping, stop_at: stop, ..Default::default() }; + self.source_playback.clock.meter.start(speed); self.start_source_audio(); Ok(()) } @@ -105,6 +115,12 @@ impl FilmcraftApp { fn start_source_audio(&mut self) { self.source_playback.clock.audio_clock = false; + if (self.source_playback.clock.speed - 1.0).abs() > 1e-9 { + if let Some(audio) = self.audio.as_mut() { + audio.stop(); + } + return; + } let Some(item) = self.source_playback.item else { return }; let Some(view) = source_view(&self.session, item) else { return }; let Some(media) = self.session.project.item(view.media).and_then(|i| i.as_media()) else { return }; @@ -221,7 +237,24 @@ impl FilmcraftApp { Some((frames, sr)) if clock.audio_clock && sr > 0 => frames as f64 / f64::from(sr), _ => (now - clock.anchor_time).max(0.0), }; - let next = Tick(clock.anchor_tick.0.saturating_add(Tick::from_seconds_f64(elapsed).0)); + let next = Tick(clock.anchor_tick.0.saturating_add(Tick::from_seconds_f64(elapsed * clock.speed).0)); + if clock.speed < 0.0 { + // backward: stops on the first frame (as the Program monitor stops at the start) + let ended = next <= view.start; + match self.session.execute("source.setPlayhead", json!({"time": next.max(view.start).0})) { + Ok(_) => self.source_playback.shown = self.session.state.source_playhead, + Err(e) => { + self.stop_source(); + self.ui.status = e.to_string(); + return; + } + } + if ended { + self.stop_source(); + } + ctx.request_repaint(); + return; + } let range = view.selected_range(); let end = clock.stop_at.unwrap_or_else(|| if clock.looping { range.end() } else { view.end }).min(view.end); let ended = next >= end; diff --git a/crates/ui-egui/tests/source_playback_ui.rs b/crates/ui-egui/tests/source_playback_ui.rs index 7ae95546..c2028303 100644 --- a/crates/ui-egui/tests/source_playback_ui.rs +++ b/crates/ui-egui/tests/source_playback_ui.rs @@ -196,3 +196,37 @@ fn a_single_frame_source_range_starts_at_the_marked_last_frame() { assert!(!h.state().source_playback.clock.playing); assert_eq!(h.state().session.state.source_playhead, last); } + +#[test] +fn j_and_l_shuttle_the_source_both_ways_and_double_the_speed() { + let mut h = Harness::builder().with_step_dt(0.25).with_size(egui::vec2(1280.0, 800.0)).build_eframe(move |_cc| app(5)); + h.step(); + h.state_mut().ui.focused = PanelKind::Source; + let before = h.state().session.project.to_json(); + let program = h.state().session.playhead(); + let ctx = h.ctx.clone(); + for speed in [1.0, 2.0, 4.0, 8.0, 8.0] { + let r = menus::invoke(h.state_mut(), &ctx, "playback.forward", json!({})).unwrap(); + assert_eq!(r["speed"], speed); + } + // J while running forward turns around at normal speed, then runs backward to the first frame + h.state_mut().session.execute("source.setPlayhead", json!({"seconds":2})).unwrap(); + assert_eq!(menus::invoke(h.state_mut(), &ctx, "playback.reverse", json!({})).unwrap()["speed"], -1.0); + let at = h.state().session.state.source_playhead; + h.step(); + h.step(); + assert!(h.state().session.state.source_playhead < at, "J plays the Source backward"); + assert!(!h.state().source_playback.clock.audio_clock, "shuttle speeds play without sound"); + assert_eq!(menus::invoke(h.state_mut(), &ctx, "playback.reverse", json!({})).unwrap()["speed"], -2.0); + for _ in 0..8 { + h.step(); + } + assert!(!h.state().source_playback.clock.playing); + assert_eq!(h.state().session.state.source_playhead, Tick::ZERO); + assert_eq!(menus::invoke(h.state_mut(), &ctx, "playback.slowForward", json!({})).unwrap()["speed"], 0.25); + menus::invoke(h.state_mut(), &ctx, "playback.stop", json!({})).unwrap(); + assert!(!h.state().source_playback.clock.playing); + assert_eq!(h.state().session.playhead(), program); + assert!(!h.state().playback.playing); + assert_eq!(h.state().session.project.to_json(), before); +} diff --git a/docs/monitors.md b/docs/monitors.md index d60ff85b..3610c9ea 100644 --- a/docs/monitors.md +++ b/docs/monitors.md @@ -43,14 +43,17 @@ Play auditions the full source span by default. Play In to Out and Play to Out address the marked Source range when Source has focus. The Source wrench menu can loop the marked range; Program keeps its separate loop setting. -Normal forward playback has sound. L starts normal forward Source playback, K stops, and the -left/right frame-step keys address Source when it has focus. Reverse and shuttle-speed Source -playback and source sequences are not yet supported and report an explanatory error. A failed -audio device leaves the picture advancing with a status message; a decoding error stops Source. +Normal forward playback has sound. With Source focused, L plays forward and J backward; pressing +the same key again doubles the speed up to 8×, K stops, Shuttle Slow Right/Left play at ¼ speed, +and the left/right frame-step keys step the Source playhead. As in Program, shuttle speeds other +than normal forward play without sound, and backward playback stops on the first frame. Source +sequences are not yet supported and report an explanatory error. A failed audio device leaves +the picture advancing with a status message; a decoding error stops Source. Commands: `source.playback.play`, `source.playback.stop`, `source.playback.toggle` (no parameters), -available through the UI command registry, CLI bridge and MCP. `playback.toggle` also accepts -`monitor: "source"|"program"` to override focus. `ui.inspect.sourcePlayback` reports playing, +available through the UI command registry, CLI bridge and MCP. `playback.toggle`, +`playback.forward`, `playback.reverse`, `playback.slowForward` and `playback.slowReverse` also +accept `monitor: "source"|"program"` to override focus. `ui.inspect.sourcePlayback` reports playing, audioClock and playhead. Test: `cargo test --release -p filmcraft-ui-egui --test source_playback_ui`. ## Drag a marked Source range to the timeline From ac8b77744768c8f86fb116c4f4456fb0a7551b56 Mon Sep 17 00:00:00 2001 From: Oskar Date: Sat, 10 Oct 2026 19:17:47 +0200 Subject: [PATCH 070/144] contributors: add myself (ossini), shown with my GitHub profile name --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 50788355..976aaa4c 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -56,3 +56,6 @@ sync_github_name = false [people.liangforstudy] sync_github_name = true + +[people.ossini] +sync_github_name = true From 25d9bff1dd79459380edb4a64a56b1b3e22022e0 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 19:18:04 +0200 Subject: [PATCH 071/144] #457: translate the UI Scale setting i18n::tests::spanish_translates_dynamic_names lists every settings label and choice; "UI Scale" and "Auto (system)" had no Spanish entry, so ui-egui's lib tests failed. Adds them to es, ja and zh-cn. 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 ++ 3 files changed, 6 insertions(+) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 08f899ec..c996891d 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3501,6 +3501,8 @@ Light Theme Tema claro Next Appearance Mode Siguiente modo de apariencia Sync with system Sincronizar con el sistema + UI Scale Escala de la interfaz + Auto (system) Automática (sistema) In Entrada Out Salida Export Markers as CSV… Exportar marcadores como CSV… diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 8dbf4419..b23fb61e 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3446,6 +3446,8 @@ Light Theme ライトテーマ Next Appearance Mode 次の外観モード Sync with system システムと同期 + UI Scale UI のスケール + Auto (system) 自動 (システム) Go to previous keyframe 前のキーフレームへ移動 Go to next keyframe 次のキーフレームへ移動 Add keyframe キーフレームを追加 diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index 731067f9..7f11f499 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -3488,6 +3488,8 @@ Appearance Mode 外观模式 Next Appearance Mode 下一个外观模式 Sync with system 跟随系统 + UI Scale 界面缩放 + Auto (system) 自动(系统) GPU Rendering GPU 渲染 Composite on the GPU when available 可用时在 GPU 上合成 Text to Speech 文字转语音 From 0c2cc293bccdaf295c473209a3387bd4a85dff51 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 19:22:28 +0200 Subject: [PATCH 072/144] #342: translate the ProRes 4444 export presets i18n::tests::spanish_translates_dynamic_names lists every built-in export preset's name and description; the three new ProRes 4444 presets had no Spanish entry, so ui-egui's lib tests failed. Adds them to es, ja and zh-cn. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/es.tsv | 6 ++++++ crates/ui-egui/src/i18n/ja.tsv | 6 ++++++ crates/ui-egui/src/i18n/zh-cn.tsv | 6 ++++++ 3 files changed, 18 insertions(+) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 08f899ec..681fe92d 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2975,6 +2975,9 @@ Apple ProRes 422 HQ Apple ProRes 422 HQ Apple ProRes 422 LT Apple ProRes 422 LT Apple ProRes 422 Proxy Apple ProRes 422 Proxy + Apple ProRes 4444 Apple ProRes 4444 + Apple ProRes 4444 with Alpha Apple ProRes 4444 con alfa + Apple ProRes 4444 XQ Apple ProRes 4444 XQ Application Aplicación Apply Guide Template Aplicar plantilla de guías Auto Save Now Guardar automáticamente ahora @@ -3184,6 +3187,9 @@ QuickTime, ProRes 422 HQ, 24-bit 48 kHz PCM QuickTime, ProRes 422 HQ, PCM de 24 bits y 48 kHz QuickTime, ProRes 422 LT, 24-bit 48 kHz PCM QuickTime, ProRes 422 LT, PCM de 24 bits y 48 kHz QuickTime, ProRes 422 Proxy, 24-bit 48 kHz PCM QuickTime, ProRes 422 Proxy, PCM de 24 bits y 48 kHz + QuickTime, ProRes 4444 (4:4:4), 24-bit 48 kHz PCM QuickTime, ProRes 4444 (4:4:4), PCM de 24 bits y 48 kHz + QuickTime, ProRes 4444 (4:4:4) with alpha channel, 24-bit 48 kHz PCM QuickTime, ProRes 4444 (4:4:4) con canal alfa, PCM de 24 bits y 48 kHz + QuickTime, ProRes 4444 XQ (4:4:4), 24-bit 48 kHz PCM QuickTime, ProRes 4444 XQ (4:4:4), PCM de 24 bits y 48 kHz QuickTime, ProRes 422, 24-bit 48 kHz PCM QuickTime, ProRes 422, PCM de 24 bits y 48 kHz Quit FilmCraft Salir de FilmCraft Rate Stretch Tool Herramienta Ajuste de velocidad diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 8dbf4419..be1f78a2 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2970,6 +2970,9 @@ Apple ProRes 422 HQ Apple ProRes 422 HQ Apple ProRes 422 LT Apple ProRes 422 LT Apple ProRes 422 Proxy Apple ProRes 422 Proxy + Apple ProRes 4444 Apple ProRes 4444 + Apple ProRes 4444 with Alpha Apple ProRes 4444(アルファ付き) + Apple ProRes 4444 XQ Apple ProRes 4444 XQ Application アプリケーション Apply Guide Template ガイドテンプレートを適用 Auto Save Now 今すぐ自動保存 @@ -3179,6 +3182,9 @@ QuickTime, ProRes 422 HQ, 24-bit 48 kHz PCM QuickTime、ProRes 422 HQ、24-bit 48 kHz PCM QuickTime, ProRes 422 LT, 24-bit 48 kHz PCM QuickTime、ProRes 422 LT、24-bit 48 kHz PCM QuickTime, ProRes 422 Proxy, 24-bit 48 kHz PCM QuickTime、ProRes 422 Proxy、24-bit 48 kHz PCM + QuickTime, ProRes 4444 (4:4:4), 24-bit 48 kHz PCM QuickTime、ProRes 4444 (4:4:4)、24-bit 48 kHz PCM + QuickTime, ProRes 4444 (4:4:4) with alpha channel, 24-bit 48 kHz PCM QuickTime、ProRes 4444 (4:4:4) アルファチャンネル付き、24-bit 48 kHz PCM + QuickTime, ProRes 4444 XQ (4:4:4), 24-bit 48 kHz PCM QuickTime、ProRes 4444 XQ (4:4:4)、24-bit 48 kHz PCM QuickTime, ProRes 422, 24-bit 48 kHz PCM QuickTime、ProRes 422、24-bit 48 kHz PCM Quit FilmCraft FilmCraft を終了 Rate Stretch Tool レートストレッチツール diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index 731067f9..44e1de7d 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -2963,6 +2963,9 @@ Apple ProRes 422 HQ Apple ProRes 422 HQ Apple ProRes 422 LT Apple ProRes 422 LT Apple ProRes 422 Proxy Apple ProRes 422 Proxy + Apple ProRes 4444 Apple ProRes 4444 + Apple ProRes 4444 with Alpha Apple ProRes 4444(带 Alpha) + Apple ProRes 4444 XQ Apple ProRes 4444 XQ Application 应用程序 Apply Guide Template 应用参考线模板 Auto Save Now 立即自动保存 @@ -3172,6 +3175,9 @@ QuickTime, ProRes 422 HQ, 24-bit 48 kHz PCM QuickTime,ProRes 422 HQ,24 位 48 kHz PCM QuickTime, ProRes 422 LT, 24-bit 48 kHz PCM QuickTime,ProRes 422 LT,24 位 48 kHz PCM QuickTime, ProRes 422 Proxy, 24-bit 48 kHz PCM QuickTime,ProRes 422 Proxy,24 位 48 kHz PCM + QuickTime, ProRes 4444 (4:4:4), 24-bit 48 kHz PCM QuickTime,ProRes 4444 (4:4:4),24 位 48 kHz PCM + QuickTime, ProRes 4444 (4:4:4) with alpha channel, 24-bit 48 kHz PCM QuickTime,ProRes 4444 (4:4:4),带 Alpha 通道,24 位 48 kHz PCM + QuickTime, ProRes 4444 XQ (4:4:4), 24-bit 48 kHz PCM QuickTime,ProRes 4444 XQ (4:4:4),24 位 48 kHz PCM QuickTime, ProRes 422, 24-bit 48 kHz PCM QuickTime,ProRes 422,24 位 48 kHz PCM Quit FilmCraft 退出 FilmCraft Rate Stretch Tool 速率伸缩工具 From 78e9221a34d587f17bb176d60bf0834db31e360f Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 19:22:42 +0200 Subject: [PATCH 073/144] #614: document timeline.thumbnails outside the ui.set row The ui.set table row is one long line; #412 (PR #656) also edits it, so the two PRs conflicted with each other in docs/control-protocol.md whichever merged second. The thumbnails parameter is now described in its own paragraph below the table, leaving that row untouched. Co-Authored-By: Claude Opus 5.5 --- docs/control-protocol.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/control-protocol.md b/docs/control-protocol.md index f13e0e7c..c75902c1 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -29,7 +29,7 @@ Methods (handlers in `crates/ui-egui/src/control.rs`): | `engine.commands` / `ui.menu.list` | – | command registry / menu tree | | `ui.inspect` | – | UI state (tool, workspace, dock, timeline view, playback, fps…) | | `ui.elements` | `{prefix?}` | every on-screen interactive element: id, label, rect | -| `ui.set` | `{tool, workspace, mode, theme, focused, playbackRes, timeline:{pps,scroll,fit,thumbnails}, program:{…}, source:{…}, clipDialog:{param: value}, menuDialog:{param: value}, panels:{scopes, timecode, reference, events, progress}, export:{…}}` | `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)); `timeline.thumbnails` is `off`, `head`, `headAndTail` or `continuous` (the wrench menu's Show Video Thumbnails); `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.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:{…}}` | `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 | @@ -41,6 +41,9 @@ Methods (handlers in `crates/ui-egui/src/control.rs`): | `ui.resize`, `ui.focus`, `app.quit` | | `ui.focus` is the only request that activates the app and takes keyboard focus | | `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)) | +**Timeline thumbnails.** `ui.set {timeline: {thumbnails}}` sets the Timeline wrench menu's Show Video +Thumbnails: `off`, `head`, `headAndTail` or `continuous`. + **Selection or explicit targets.** A command that acts on the selection is "not available right now" when nothing is selected. When `params` name the targets under a key the command documents (`clips` / `clip`: timeline clips of the active sequence, `items` / `item`: project items), enablement is From a4050b4ffce609b455a9423e76df8c3130fb700e Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 19:22:52 +0200 Subject: [PATCH 074/144] #411: importing a folder keeps its structure as bins Problem: mediaBrowser.import flattened a folder into its first-level files (not recursive) and created no bins, so sub-folders were skipped and the folder hierarchy was lost in the project. Fix: a folder becomes a bin named after it (in `bin`, else the root) holding its files, and each sub-folder with media a bin inside that, as Premiere's Media Browser does. Folders without media get no bin. The walk is bounded (depth 32, 4096 folders) against symlink loops. The result also lists the new `bins`. Import as Image Sequence and plain file imports behave as before. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/media_browser.rs | 124 +++++++++++++++++++++-- crates/engine/src/media_browser_tests.rs | 41 ++++++++ 2 files changed, 154 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/media_browser.rs b/crates/engine/src/media_browser.rs index 515a10d4..b5a4d5da 100644 --- a/crates/engine/src/media_browser.rs +++ b/crates/engine/src/media_browser.rs @@ -12,7 +12,7 @@ //! | `mediaBrowser.favorite` | Add to / Remove from Favorites | //! | `mediaBrowser.clearRecent` | Clear Recent Directories | //! | `mediaBrowser.settings` | file types, view (list / thumbnails), columns (Edit Columns…), Import as Image Sequence, Hover Scrub, thumbnail size | -//! | `mediaBrowser.import` | Import (the selection or `paths`; Project ▸ Ingest settings apply) | +//! | `mediaBrowser.import` | Import (the selection or `paths`; a folder becomes a bin, its sub-folders bins inside it; Project ▸ Ingest settings apply) | //! | `mediaBrowser.openInSource` | Open In Source Monitor (Shift+O) | //! | `mediaBrowser.probe` | a file's media properties (frame rate, duration, video / audio info), cached | //! @@ -421,32 +421,134 @@ fn paths_p(s: &Session, p: &Value) -> Vec { } } -/// `mediaBrowser.import {paths?, bin?, imageSequence?}`: imports files (directories import their -/// supported files, like dragging a folder in Premiere). +/// Most folders one import walks, and how deep it goes: a folder import mirrors the tree as bins, +/// and symlinked folders can loop. +const FOLDER_IMPORT_MAX_FOLDERS: usize = 4096; +const FOLDER_IMPORT_MAX_DEPTH: usize = 32; + +/// A folder to import as a bin: its name, its files and its sub-folders that hold files. +struct Folder { + name: String, + files: Vec, + subs: Vec, +} + +/// The files the browser shows in `dir` and its sub-folders, as a tree; `None` when the tree +/// holds none (or `budget` folders were walked already). +fn walk_folder(services: &dyn Services, dir: &str, depth: usize, budget: &mut usize) -> Option { + *budget = budget.checked_sub(1)?; + let entries = list(services, dir, "all").ok()?; + let mut folder = Folder { name: base_name(dir), files: Vec::new(), subs: Vec::new() }; + for e in entries { + if !e.is_dir { + folder.files.push(e.path); + } else if depth < FOLDER_IMPORT_MAX_DEPTH + && let Some(sub) = walk_folder(services, &e.path, depth + 1, budget) + { + folder.subs.push(sub); + } + } + (!folder.files.is_empty() || !folder.subs.is_empty()).then_some(folder) +} + +/// Add a `file.import` result to the merged one: arrays add up, other values (the Ingest result) +/// are listed. +fn merge_import(out: &mut serde_json::Map, r: Value) { + let Value::Object(r) = r else { return }; + for (k, v) in r { + let slot = out.entry(k).or_insert_with(|| json!([])); + let Value::Array(all) = slot else { continue }; + match v { + Value::Null => {} + Value::Array(a) => all.extend(a), + other => all.push(other), + } + } +} + +/// Run `file.import` and merge its result; a failure becomes an error of the merged result. +fn import_into(s: &mut Session, files: &[String], bin: Option, out: &mut serde_json::Map) { + let mut q = json!({"paths": files, "imageSequence": false}); + if let Some(b) = bin { + q["bin"] = json!(b); + } + match s.execute("file.import", q) { + Ok(r) => merge_import(out, r), + Err(e) => merge_import(out, json!({"errors": [e.to_string()]})), + } +} + +/// Import a folder as a bin named after it, in `parent`: its files go into the bin, each +/// sub-folder becomes a bin inside it (Premiere's Media Browser keeps the folder structure). +fn import_folder(s: &mut Session, folder: &Folder, parent: Option, out: &mut serde_json::Map) -> Result<()> { + let name = folder.name.clone(); + let bin = s.edit("New Bin", |pr, _| Ok(pr.add_bin(&name, parent.map(filmcraft_project::BinId))))?; + merge_import(out, json!({"bins": [bin.0]})); + if !folder.files.is_empty() { + import_into(s, &folder.files, Some(bin.0), out); + } + // recursion as deep as the walk went, which is bounded + for sub in &folder.subs { + import_folder(s, sub, Some(bin.0), out)?; + } + Ok(()) +} + +/// `mediaBrowser.import {paths?, bin?, imageSequence?}`: imports files. A folder becomes a bin +/// named after it (in `bin`) holding its files, and each sub-folder a bin inside that, as in +/// Premiere; the result then also lists the new `bins`. Import as Image Sequence takes the first +/// file instead. fn import(s: &mut Session, p: &Value) -> Result { let paths = paths_p(s, p); if paths.is_empty() { return Err(bad("mediaBrowser.import", "select files in the Media Browser")); } let seq = bool_p(p, "imageSequence").unwrap_or(s.prefs.media_browser.import_as_image_sequence); + let bin = u64_p(p, "bin"); let mut files = Vec::new(); + let mut folders = Vec::new(); + let mut empty = Vec::new(); + let mut budget = FOLDER_IMPORT_MAX_FOLDERS; for path in paths { - // a directory imports its media files (not recursive) let is_dir = matches!(s.services.list_entries(&path), Some(Ok(_))) && kind_of(&path).is_none(); - match list(&*s.services, &path, "all") { - Ok(entries) if is_dir => files.extend(entries.into_iter().filter(|e| !e.is_dir).map(|e| e.path)), - _ => files.push(path), + if !is_dir { + files.push(path); + } else if seq { + // an image sequence: the folder's frames + match list(&*s.services, &path, "all") { + Ok(entries) => files.extend(entries.into_iter().filter(|e| !e.is_dir).map(|e| e.path)), + Err(_) => files.push(path), + } + } else { + match walk_folder(&*s.services, &path, 0, &mut budget) { + Some(f) => folders.push(f), + None => empty.push(path), + } } } // an image sequence imports from its first selected frame only if seq { files.truncate(1); } - let mut q = json!({"paths": files, "imageSequence": seq}); - if let Some(b) = u64_p(p, "bin") { - q["bin"] = json!(b); + if folders.is_empty() { + if files.is_empty() && !empty.is_empty() { + return Err(bad("mediaBrowser.import", format!("no media files in {}", empty.join(", ")))); + } + let mut q = json!({"paths": files, "imageSequence": seq}); + if let Some(b) = bin { + q["bin"] = json!(b); + } + return s.execute("file.import", q); } - s.execute("file.import", q) + let mut out = serde_json::Map::new(); + merge_import(&mut out, json!({"items": [], "errors": empty.iter().map(|e| format!("{e}: no media files")).collect::>(), "bins": []})); + if !files.is_empty() { + import_into(s, &files, bin, &mut out); + } + for f in &folders { + import_folder(s, f, bin, &mut out)?; + } + Ok(Value::Object(out)) } /// The project item that already references a file. diff --git a/crates/engine/src/media_browser_tests.rs b/crates/engine/src/media_browser_tests.rs index 972220b3..aa1b5938 100644 --- a/crates/engine/src/media_browser_tests.rs +++ b/crates/engine/src/media_browser_tests.rs @@ -210,6 +210,47 @@ fn import_the_selection_and_open_in_source() { assert_eq!(r["items"].as_array().unwrap().len(), 1); } +/// Importing a folder keeps its structure (#411): the folder becomes a bin named after it, each +/// sub-folder with media a bin inside that, and every file lands in its folder's bin. +#[test] +fn importing_a_folder_mirrors_its_sub_folders_as_bins() { + let mut fs = FakeFs::new(); + let wav = filmcraft_media::wav::write_wav16(&[0.0; 4800], 2, 48_000); + fs.dirs.extend(["/Shoot", "/Shoot/Day 1", "/Shoot/Day 1/Cam A", "/Shoot/Empty"].map(String::from)); + for f in ["/Shoot/slate.wav", "/Shoot/Day 1/room.wav", "/Shoot/Day 1/Cam A/take1.wav", "/Shoot/Day 1/Cam A/take2.wav"] { + fs.files.get_mut().unwrap().insert(f.into(), wav.clone()); + } + let mut s = Session::new(Arc::new(fs)); + let r = s.execute("mediaBrowser.import", json!({"paths": ["/Shoot"]})).unwrap(); + assert_eq!(r["items"].as_array().unwrap().len(), 4, "{r}"); + assert_eq!(r["bins"].as_array().unwrap().len(), 3, "{r}"); + let tree = |b: &filmcraft_project::Bin| -> Vec { + b.children + .iter() + .map(|c| match c { + filmcraft_project::BinEntry::Bin(b) => format!("bin {}", b.name), + filmcraft_project::BinEntry::Item(i) => s.project.item(*i).unwrap().name.clone(), + }) + .collect() + }; + let sub = |b: &filmcraft_project::Bin, name: &str| -> filmcraft_project::Bin { + b.children + .iter() + .find_map(|c| match c { + filmcraft_project::BinEntry::Bin(b) if b.name == name => Some(b.clone()), + _ => None, + }) + .unwrap() + }; + assert_eq!(tree(&s.project.root), ["bin Shoot"]); + let shoot = sub(&s.project.root, "Shoot"); + // a folder without media gets no bin + assert_eq!(tree(&shoot), ["slate.wav", "bin Day 1"]); + let day = sub(&shoot, "Day 1"); + assert_eq!(tree(&day), ["room.wav", "bin Cam A"]); + assert_eq!(tree(&sub(&day, "Cam A")), ["take1.wav", "take2.wav"]); +} + #[test] fn import_with_ingest_settings() { let mut s = session(); From 6d46d4f315295c82b155051c5ec1e2a68bc93be7 Mon Sep 17 00:00:00 2001 From: ReReddo <249398616+ReReddo@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:24:18 +0300 Subject: [PATCH 075/144] timeline: lines between the tracks, a short playhead head - The line under each track was drawn in the Timeline background colour, the same as the tracks, so the tracks ran into each other. It now uses the separator colour the track headers already use, and the top video track also gets a line above it, in the headers and the track area. - The playhead's head ran the full height of the ruler and covered the markers and the timecodes. It is now the short head Effect Controls uses, on the tick row. --- crates/ui-egui/src/panels/timeline.rs | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index d5997839..59342fed 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -355,7 +355,11 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } let bg = if r.index % 2 == 0 { t.tl_track_bg } else { t.tl_track_bg_alt }; painter.rect_filled(row, 0.0, bg); - painter.line_segment([pos2(row.min.x, r.rect.max.y - 0.5), pos2(row.max.x, r.rect.max.y - 0.5)], Stroke::new(1.0, t.tl_bg)); + painter.line_segment([pos2(row.min.x, r.rect.max.y - 0.5), pos2(row.max.x, r.rect.max.y - 0.5)], Stroke::new(1.0, t.separator)); + // the top video track also gets a line above it + if r.kind == TrackKind::Video && r.index + 1 == nv { + painter.line_segment([pos2(row.min.x, r.rect.min.y + 0.5), pos2(row.max.x, r.rect.min.y + 0.5)], Stroke::new(1.0, t.separator)); + } } // in/out shading across tracks if seq.mark_in.is_some() || seq.mark_out.is_some() { @@ -491,15 +495,11 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let ph = app.session.playhead(); let px = layout.x_of(ph); if px >= content.min.x - 1.0 && px <= content.max.x + 1.0 { - let head = [ - pos2(px - 6.0, ruler.min.y + 2.0), - pos2(px + 6.0, ruler.min.y + 2.0), - pos2(px + 6.0, ruler.max.y - 10.0), - pos2(px, ruler.max.y - 4.0), - pos2(px - 6.0, ruler.max.y - 10.0), - ]; + // a short head on the tick row, as in Effect Controls, so it leaves the markers and timecodes clear + let (top, tip) = (ruler.max.y - 13.0, ruler.max.y); + let head = [pos2(px - 5.0, top), pos2(px + 5.0, top), pos2(px + 5.0, tip - 5.0), pos2(px, tip), pos2(px - 5.0, tip - 5.0)]; painter.add(egui::Shape::convex_polygon(head.to_vec(), t.playhead, Stroke::NONE)); - painter.line_segment([pos2(px, ruler.max.y - 4.0), pos2(px, content.max.y)], Stroke::new(1.0, t.playhead)); + painter.line_segment([pos2(px, tip), pos2(px, content.max.y)], Stroke::new(1.0, t.playhead)); } // snap indicator if let Some(sx) = app.tl.snap_x.take() { @@ -989,6 +989,9 @@ fn draw_headers(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, rows: let p = ui.painter().with_clip_rect(visible); p.rect_filled(hrect, 0.0, t.tl_header_bg); p.line_segment([pos2(hrect.min.x, hrect.max.y - 0.5), pos2(hrect.max.x, hrect.max.y - 0.5)], Stroke::new(1.0, t.separator)); + if r.kind == TrackKind::Video && r.index + 1 == seq.video_tracks.len() { + p.line_segment([pos2(hrect.min.x, hrect.min.y + 0.5), pos2(hrect.max.x, hrect.min.y + 0.5)], Stroke::new(1.0, t.separator)); + } let label = format!("{}{}", if r.kind == TrackKind::Video { "V" } else { "A" }, r.index + 1); let btn_rect = |x0: f32| Rect::from_min_max(pos2(hrect.min.x + x0, hrect.min.y + 1.0), pos2(hrect.min.x + x0 + 24.0, hrect.max.y - 2.0)); // 1. source patch (absent when unpatched) From 6aa4146c162839c3fbe734ab0d3195bb2b1b7684 Mon Sep 17 00:00:00 2001 From: impostazioni <25422260+impostazioni@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:29:09 +0100 Subject: [PATCH 076/144] Captions: render the track font and add a font picker The caption style's `font` was stored and settable through captions.setStyle, but the burn-in renderer always drew Inter SemiBold. Lay out, wrap and measure captions with the track's family (Inter when the name is empty or not installed), so the Program monitor and burned-in exports use the chosen font. Add a font dropdown to the caption style strip in the Text panel's Captions tab (automation id `text.captions.style.font`, options `text.captions.style.font.option.`, registered only while visible). macOS-private faces (names starting with '.') are not offered: some lack a space glyph and run words together. Tests: renderer regression test (a non-Inter family changes the pixels; failed before), fallback test for empty/unknown names, and a headless UI test that picks Noto Serif from the dropdown. Co-Authored-By: Claude Opus 5.5 --- crates/captions/src/burn.rs | 45 +++++++---- crates/project/src/caption.rs | 2 +- crates/ui-egui/src/panels/text.rs | 16 ++++ crates/ui-egui/tests/captions_font_ui.rs | 95 ++++++++++++++++++++++++ 4 files changed, 144 insertions(+), 14 deletions(-) create mode 100644 crates/ui-egui/tests/captions_font_ui.rs diff --git a/crates/captions/src/burn.rs b/crates/captions/src/burn.rs index 5c43e525..77c9bf84 100644 --- a/crates/captions/src/burn.rs +++ b/crates/captions/src/burn.rs @@ -49,13 +49,10 @@ impl Overlay { } } -/// The caption face: Inter SemiBold at `px`. -fn caption_style(px: f32) -> TextStyle { - TextStyle { style: "SemiBold".into(), size: px, ..Default::default() } -} - -fn measure(text: &str, px: f32) -> f32 { - filmcraft_text::measure(text, &caption_style(px)) +/// The caption face: the track's font family (Inter when empty or not installed) in SemiBold at `px`. +fn caption_style(font: &str, px: f32) -> TextStyle { + let family = if font.trim().is_empty() { filmcraft_text::fonts::DEFAULT_FAMILY } else { font.trim() }; + TextStyle { family: family.into(), style: "SemiBold".into(), size: px, ..Default::default() } } fn lin(c: [u8; 4]) -> [f32; 4] { @@ -64,12 +61,12 @@ fn lin(c: [u8; 4]) -> [f32; 4] { } /// Word-wrap one line to `max_w` pixels. -fn wrap(line: &str, px: f32, max_w: f32) -> Vec { +fn wrap(line: &str, ts: &TextStyle, max_w: f32) -> Vec { let mut out = Vec::new(); let mut cur = String::new(); for word in line.split(' ') { let cand = if cur.is_empty() { word.to_string() } else { format!("{cur} {word}") }; - if !cur.is_empty() && measure(&cand, px) > max_w { + if !cur.is_empty() && filmcraft_text::measure(&cand, ts) > max_w { out.push(std::mem::take(&mut cur)); cur = word.to_string(); } else { @@ -115,16 +112,17 @@ pub fn render_caption(c: &Caption, style: &CaptionStyle, w: usize, h: usize) -> let scale = h as f32 / 1080.0; let px = (style.size * scale).max(4.0); let max_w = w as f32 * 0.9; + let ts = caption_style(&style.font, px); let mut lines: Vec = Vec::new(); // (speaker names are metadata; like Premiere, they are not burned in) for l in c.plain_lines() { - lines.extend(wrap(l.trim(), px, max_w)); + lines.extend(wrap(l.trim(), &ts, max_w)); } lines.retain(|l| !l.is_empty()); if lines.is_empty() { return None; } - let vm = filmcraft_text::fonts::face(filmcraft_text::resolve("Inter", "SemiBold").face).metrics(px); + 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 pad_x = (px * 0.3).round(); @@ -141,7 +139,7 @@ pub fn render_caption(c: &Caption, style: &CaptionStyle, w: usize, h: usize) -> .max(0.0) .round(); let side = w as f32 * 0.05; - let widths: Vec = lines.iter().map(|l| measure(l, px)).collect(); + let widths: Vec = lines.iter().map(|l| filmcraft_text::measure(l, &ts)).collect(); let xs: Vec = widths .iter() .map(|&lw| match align { @@ -176,7 +174,7 @@ pub fn render_caption(c: &Caption, style: &CaptionStyle, w: usize, h: usize) -> } } let baseline = lt + (lh - (asc + desc)) / 2.0 + asc; - let l = layout(line, &caption_style(px), &ParagraphStyle::default()); + let l = layout(line, &ts, &ParagraphStyle::default()); let mut m = filmcraft_text::Mask { w: ow, h: oh, a: std::mem::take(&mut cover) }; render::draw(&l, &render::at(xs[i], baseline.round()), &mut m, (ox, oy)); cover = m.a; @@ -305,6 +303,27 @@ mod tests { assert!(o.px.chunks(4).any(|p| p[0] > 0.9 && p[1] < 0.05 && p[3] > 0.9), "red outline pixels"); } + #[test] + fn track_font_is_used() { + // the style's font used to be ignored (always Inter) + let inter = render_caption(&cap("Hello world"), &CaptionStyle::default(), 1920, 1080).unwrap(); + let serif = CaptionStyle { font: "Noto Serif".into(), ..Default::default() }; + let serif = render_caption(&cap("Hello world"), &serif, 1920, 1080).unwrap(); + assert_ne!((inter.w, &inter.px), (serif.w, &serif.px), "Noto Serif renders like Inter"); + let mono = CaptionStyle { font: "jetbrains mono".into(), ..Default::default() }; + let mono = render_caption(&cap("Hello world"), &mono, 1920, 1080).unwrap(); + assert_ne!((inter.w, &inter.px), (mono.w, &mono.px), "JetBrains Mono renders like Inter"); + } + + #[test] + fn unknown_or_empty_font_falls_back_to_inter() { + let inter = render_caption(&cap("Hello"), &CaptionStyle::default(), 1280, 720).unwrap(); + for font in ["", " ", "No Such Font 123"] { + let st = CaptionStyle { font: font.into(), ..Default::default() }; + assert_eq!(render_caption(&cap("Hello"), &st, 1280, 720), Some(inter.clone()), "font {font:?}"); + } + } + #[test] fn hidden_track_draws_nothing() { let mut t = CaptionTrack::new(TrackId(1), "S".into(), CaptionFormat::Subtitle); diff --git a/crates/project/src/caption.rs b/crates/project/src/caption.rs index 013c6efc..d8236e8b 100644 --- a/crates/project/src/caption.rs +++ b/crates/project/src/caption.rs @@ -74,7 +74,7 @@ pub enum CaptionAnchor { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct CaptionStyle { - /// Font family name. Only the bundled Inter is rendered today; other names fall back to it. + /// Font family name (bundled, craft-fonts or system); unknown or empty names fall back to Inter. pub font: String, /// Font size in pixels for a 1080-line frame (scaled with the frame height). pub size: f32, diff --git a/crates/ui-egui/src/panels/text.rs b/crates/ui-egui/src/panels/text.rs index 4408779d..e5b83ba6 100644 --- a/crates/ui-egui/src/panels/text.rs +++ b/crates/ui-egui/src/panels/text.rs @@ -385,6 +385,22 @@ fn style_strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, track_idx: us let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r).id_salt("caption-style")); child.horizontal_centered(|ui| { ui.spacing_mut().item_spacing.x = 6.0; + let r = egui::ComboBox::from_id_salt(("caption-font", tr.id.0)).selected_text(&st.font).width(130.0).height(400.0).show_ui(ui, |ui| { + if !filmcraft_text::fonts::system_scanned() { + filmcraft_text::fonts::scan_system(); + } + // names starting with '.' are macOS-private system faces (UI/fallback fonts, some without a space glyph) + for (f, _) in filmcraft_text::families().into_iter().filter(|(f, _)| !f.starts_with('.')) { + let resp = ui.selectable_label(f.eq_ignore_ascii_case(&st.font), &f); + if ui.is_rect_visible(resp.rect) { + app.auto.add(&format!("text.captions.style.font.option.{f}"), resp.rect, &f); + } + if resp.clicked() { + actions.push(("captions.setStyle".into(), json!({"track": tr.id.0, "font": f}))); + } + } + }); + app.auto.add("text.captions.style.font", r.response.rect, "Caption font"); let mut size = st.size; let resp = ui.add(egui::DragValue::new(&mut size).range(8.0..=200.0).speed(0.5).suffix(" px")); app.auto.add("text.captions.style.size", resp.rect, "Caption size"); diff --git a/crates/ui-egui/tests/captions_font_ui.rs b/crates/ui-egui/tests/captions_font_ui.rs new file mode 100644 index 00000000..bd6822d5 --- /dev/null +++ b/crates/ui-egui/tests/captions_font_ui.rs @@ -0,0 +1,95 @@ +//! Headless UI test of the caption track font picker in the Text panel's Captions tab: picking a +//! family sets the track style's font, and macOS-private (`.`-prefixed) faces are not offered. + +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); + let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.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 ids(&mut self, prefix: &str) -> Vec { + let v = self.ok("ui.elements", json!({"prefix": prefix})); + v.as_array().unwrap().iter().filter_map(|e| e["id"].as_str().map(str::to_string)).collect() + } + + fn font(&mut self, track: u64) -> String { + let r = self.ok("engine.execute", json!({"command": "captions.list", "params": {}})); + let t = r["tracks"].as_array().unwrap().iter().find(|t| t["id"] == json!(track)).expect("track").clone(); + t["style"]["font"].as_str().unwrap().to_string() + } +} + +#[test] +fn caption_font_picker_sets_the_track_font() { + let mut d = Driver::demo(); + d.ok("ui.set", json!({"workspace": "Captions and Graphics"})); + let track = d.ok("engine.execute", json!({"command": "captions.newTrack", "params": {"format": "Subtitle"}}))["track"].as_u64().unwrap(); + d.ok("engine.execute", json!({"command": "captions.add", "params": {"track": track, "text": "Hello", "seconds": 1.0}})); + d.frames(2); + d.ok("ui.click", json!({"id": "text.tab.Captions"})); + d.frames(3); + assert_eq!(d.font(track), "Inter"); + + d.ok("ui.click", json!({"id": "text.captions.style.font"})); + d.frames(3); + let options = d.ids("text.captions.style.font.option."); + assert!(!options.is_empty(), "the font list is open"); + assert!(!options.iter().any(|o| o.starts_with("text.captions.style.font.option..")), "private faces offered: {options:?}"); + // with system fonts the list is long: scroll until the bundled serif is on screen + let serif = "text.captions.style.font.option.Noto Serif"; + for _ in 0..200 { + let shown = d.ids("text.captions.style.font.option."); + if shown.iter().any(|o| o == serif) { + break; + } + // wheel over a row in the middle (the edge rows are clipped by the popup) + let Some(mid) = shown.get(shown.len() / 2) else { break }; + d.ok("ui.scroll", json!({"id": mid, "dy": -120.0})); + d.frames(2); + } + d.ok("ui.click", json!({"id": serif})); + d.frames(3); + assert_eq!(d.font(track), "Noto Serif"); +} From 7f4bf2157b28437200b651fa067c6b0c69dbd91e Mon Sep 17 00:00:00 2001 From: Manuel Camacho Date: Sat, 10 Oct 2026 10:46:20 -0700 Subject: [PATCH 077/144] HW6: AV1 export format, enabled where a hardware encoder registers one (#30) Format::Av1 in export settings and jobs, SampleEntry::av1 (av01 + av1C) in isobmff, and the Export panel's Format list, profile and encoder rows. No encoder ships here: like H.265, AV1 is listed greyed out until a platform encoder registers a probe, and a job without one fails with "AV1 encoder not available yet". Split out of #409; the VA-API AV1 encoder follows on main's vaapi::va design. Co-Authored-By: Claude Opus 5.5 --- crates/export/src/job.rs | 9 ++++-- crates/export/src/lib.rs | 27 +++++++++++++--- crates/export/src/settings.rs | 16 ++++++--- crates/export/src/settings_tests.rs | 33 ++++++++++++++++++- crates/isobmff/src/codec.rs | 4 +++ 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 | 41 +++++++++++++----------- 9 files changed, 106 insertions(+), 30 deletions(-) diff --git a/crates/export/src/job.rs b/crates/export/src/job.rs index 88864eaf..1154f99f 100644 --- a/crates/export/src/job.rs +++ b/crates/export/src/job.rs @@ -72,7 +72,7 @@ use rayon::prelude::*; use crate::audio_out::AudioOut; use crate::mxf_out::{MxfMux, MxfSetup}; use crate::pipeline::Pipeline; -use crate::settings::{AudioCodec, BitrateMode, Multiplexer}; +use crate::settings::{AudioCodec, BitrateMode}; use crate::{ AudioEncoder, ColorSignal, EncodedPacket, EncoderFrame, ExportError, ExportSettings, Format, H264Pass, Out, Progress, Report, Result, Stage, VideoEncoder, audio_factories, export_range, frame_span, note_stage, timed, video_factories, @@ -186,7 +186,10 @@ pub struct Exporter { /// Whether [`Exporter`] handles a format. pub fn stepped(format: Format) -> bool { - matches!(format, Format::H264 | Format::Hevc | Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg | Format::MxfOp1a | Format::MxfOpAtom) + matches!( + format, + Format::H264 | Format::Hevc | Format::Av1 | Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg | Format::MxfOp1a | Format::MxfOpAtom + ) } /// Whether an export is HDR: an HDR working space, SDR not asked for, and a format whose encoder @@ -249,7 +252,7 @@ impl Exporter { progress.set_status(format!("Exporting {} frames ({})", nframes, settings.format.label())); } let venc = make_venc(&settings, pipe.w, pipe.h, pipe.rate)?; - let brand = if settings.format.is_h26x() && settings.multiplexer == Multiplexer::Mp4 { Brand::Mp4 } else { Brand::Mov }; + let brand = if settings.format.is_mp4_with(settings.multiplexer) { Brand::Mp4 } else { Brand::Mov }; let audio = if settings.has_audio() { Some(AudioOut::new(project.clone(), seq, &settings, range)?) } else { None }; let aenc: Option> = match (&audio, settings.audio_codec()) { (Some(a), AudioCodec::Aac) => { diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 312d0301..48b1ca2e 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -70,6 +70,10 @@ pub enum Format { /// ([`hdr_available`]); elsewhere they are tone-mapped to SDR. #[serde(rename = "hevc", alias = "Hevc")] Hevc, + /// MPEG-4, AV1 Main (8-bit) video + AAC audio. Like H.265 it has no built-in encoder: offered + /// where a hardware encoder registers one. + #[serde(rename = "av1", alias = "Av1")] + Av1, /// QuickTime, Apple ProRes 422 (HQ unless the settings pick another flavour) + PCM. #[serde(rename = "prores", alias = "ProRes")] ProRes, @@ -112,6 +116,7 @@ impl Format { Some(match s.to_ascii_lowercase().replace([' ', '-', '_', '.'], "").as_str() { "h264" | "mp4" | "avc" | "m4v" => Format::H264, "hevc" | "h265" | "hvc1" | "hev1" | "x265" => Format::Hevc, + "av1" | "av01" => Format::Av1, "prores" | "mov" | "appleprores" => Format::ProRes, "dnxhr" | "dnxhd" | "dnx" | "avid" | "aviddnxhr" | "aviddnxhd" | "vc3" => Format::DnxHr, "apv" | "apv1" => Format::Apv, @@ -132,6 +137,7 @@ impl Format { match self { Format::H264 => "h264", Format::Hevc => "hevc", + Format::Av1 => "av1", Format::ProRes => "prores", Format::DnxHr => "dnxhr", Format::Apv => "apv", @@ -148,7 +154,7 @@ impl Format { } pub fn extension(self) -> &'static str { match self { - Format::H264 | Format::Hevc => "mp4", + Format::H264 | Format::Hevc | Format::Av1 => "mp4", Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg => "mov", Format::PngSequence => "png", Format::TiffSequence => "tif", @@ -163,6 +169,7 @@ impl Format { match self { Format::H264 => "H.264", Format::Hevc => "H.265 (HEVC)", + Format::Av1 => "AV1", Format::ProRes => "Apple ProRes", Format::DnxHr => "Avid DNxHR", Format::Apv => "APV", @@ -184,15 +191,24 @@ impl Format { /// Whether the crate carries an encoder for the format; the others need one registered at runtime /// ([`register_format_probe`]). pub fn has_builtin_encoder(self) -> bool { - self != Format::Hevc + !matches!(self, Format::Hevc | Format::Av1) } /// H.264 or H.265: MPEG-4 (or QuickTime) with AAC audio, set up with the same bitrate controls. pub fn is_h26x(self) -> bool { matches!(self, Format::H264 | Format::Hevc) } - pub const ALL: [Format; 14] = [ + /// A bitrate-controlled MPEG-4 video format with AAC audio: H.264, H.265 or AV1 (MPEG-4 only). + pub fn is_mp4_video(self) -> bool { + matches!(self, Format::H264 | Format::Hevc | Format::Av1) + } + /// Whether the file is MPEG-4 rather than QuickTime for `mux`. + pub fn is_mp4_with(self, mux: Multiplexer) -> bool { + self == Format::Av1 || (self.is_h26x() && mux == Multiplexer::Mp4) + } + pub const ALL: [Format; 15] = [ Format::H264, Format::Hevc, + Format::Av1, Format::ProRes, Format::DnxHr, Format::Apv, @@ -524,6 +540,9 @@ impl ExportSettings { if self.format == Format::Hevc && self.bitrate_mode == BitrateMode::Vbr2Pass { return Err(ExportError::Unsupported("H.265 export has no two-pass mode: choose CBR or VBR, 1 pass".into())); } + if self.format == Format::Av1 && self.bitrate_mode == BitrateMode::Vbr2Pass { + return Err(ExportError::Unsupported("AV1 export has no two-pass mode: choose CBR or VBR, 1 pass".into())); + } Ok(()) } } @@ -1641,7 +1660,7 @@ pub fn export(project: &Arc, seq: ItemId, settings: &ExportSettings, so } (total, count) } - Format::H264 | Format::Hevc | Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg | Format::MxfOp1a | Format::MxfOpAtom => { + Format::H264 | Format::Hevc | Format::Av1 | Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg | Format::MxfOp1a | Format::MxfOpAtom => { // Handled by the stepped exporter above; reaching here would be a dispatch bug. return Err(ExportError::Unsupported(format!("{:?} must run as a stepped export", settings.format))); } diff --git a/crates/export/src/settings.rs b/crates/export/src/settings.rs index 8e8fc7ef..0acb2f93 100644 --- a/crates/export/src/settings.rs +++ b/crates/export/src/settings.rs @@ -422,8 +422,8 @@ impl ExportSettings { pub fn audio_codec(&self) -> AudioCodec { match (self.format, self.audio.codec) { (Format::Wav | Format::Aiff | Format::MxfOp1a | Format::MxfOpAtom, _) => AudioCodec::Pcm, - (f, _) if f.is_h26x() && self.multiplexer == Multiplexer::Mp4 => AudioCodec::Aac, - (_, AudioCodec::Auto) if self.format.is_h26x() => AudioCodec::Aac, + (f, _) if f.is_mp4_with(self.multiplexer) => AudioCodec::Aac, + (_, AudioCodec::Auto) if self.format.is_mp4_video() => AudioCodec::Aac, (_, AudioCodec::Auto) => AudioCodec::Pcm, (_, c) => c, } @@ -451,7 +451,7 @@ impl ExportSettings { let fps = r.rate.num as f64 / r.rate.den as f64; let px = r.width as f64 * r.height as f64; let video_bps = match self.video_format() { - Format::H264 | Format::Hevc => r.target_kbps as f64 * 1000.0, + Format::H264 | Format::Hevc | Format::Av1 => r.target_kbps as f64 * 1000.0, Format::ProRes => crate::prores_profile(&self.prores_profile).nominal_mbps_1080p30() * 1e6 * px / (1920.0 * 1080.0) * fps / 29.97, Format::DnxHr => { // nominal 1080p29.97 data rates of the DNxHR profiles (Mb/s) @@ -534,6 +534,14 @@ impl ExportSettings { }; v += &format!(", keyframe every {} frames", r.keyint); } + Format::Av1 => { + v += &format!(", AV1 Main (hardware encoder), {}", self.bitrate_mode.label()); + v += &match self.bitrate_mode { + BitrateMode::Cbr => format!(", {}", mbps(r.target_kbps)), + _ => format!(", Target {}, Max {}", mbps(r.target_kbps), mbps(r.max_kbps)), + }; + v += &format!(", keyframe every {} frames", r.keyint); + } Format::ProRes => { use filmcraft_prores::Profile; v += match crate::prores_profile(&self.prores_profile) { @@ -565,7 +573,7 @@ impl ExportSettings { }; let container = match self.format { Format::H264 | Format::Hevc if self.multiplexer == Multiplexer::Mov => "QuickTime", - Format::H264 | Format::Hevc => "MP4", + Format::H264 | Format::Hevc | Format::Av1 => "MP4", Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg => "QuickTime", Format::MxfOp1a | Format::MxfOpAtom => self.mxf_video_codec.label(), Format::PngSequence | Format::TiffSequence | Format::BmpSequence => "Image sequence", diff --git a/crates/export/src/settings_tests.rs b/crates/export/src/settings_tests.rs index b494226b..80a53c1b 100644 --- a/crates/export/src/settings_tests.rs +++ b/crates/export/src/settings_tests.rs @@ -487,6 +487,37 @@ fn a_time_left_reads_like_a_clock() { assert!(!format_eta(Duration::MAX).is_empty(), "the largest duration cannot overflow"); } +#[test] +fn av1_is_an_mp4_format_that_needs_a_registered_encoder() { + for name in ["av1", "AV1", "av01"] { + assert_eq!(Format::from_name(name), Some(Format::Av1), "{name}"); + } + assert_eq!(Format::from_name(Format::Av1.id()), Some(Format::Av1)); + assert_eq!((Format::Av1.extension(), Format::Av1.label()), ("mp4", "AV1")); + assert!(Format::ALL.contains(&Format::Av1)); + let s: ExportSettings = serde_json::from_value(serde_json::json!({"format": "av1"})).unwrap(); + assert_eq!(s.format, Format::Av1); + assert_eq!(serde_json::to_value(&s).unwrap()["format"], "av1"); + // MP4 with AAC only: a QuickTime multiplexer setting left over from H.264 does not apply + let a = ExportSettings { format: Format::Av1, ..Default::default() }; + assert_eq!((a.audio_codec(), a.extension()), (AudioCodec::Aac, "mp4")); + let mov = ExportSettings { multiplexer: Multiplexer::Mov, ..a.clone() }; + assert_eq!((mov.audio_codec(), mov.extension()), (AudioCodec::Aac, "mp4")); + let b = a.estimate_bytes(1920, 1088, FrameRate::FPS_25, 48_000, Tick(10 * TICKS_PER_SECOND)); + assert!((b as f64 - 25.4e6).abs() < 0.2e6, "{b}"); + let summary = a.summary(1920, 1088, FrameRate::FPS_25, 48_000, Tick(10 * TICKS_PER_SECOND)); + assert_eq!(summary.format, "AV1 (MP4)"); + assert!(summary.video.contains("AV1 Main"), "{}", summary.video); + let e = ExportSettings { bitrate_mode: BitrateMode::Vbr2Pass, ..a.clone() }.validate().unwrap_err(); + assert!(e.to_string().contains("two-pass"), "{e}"); + assert!(!Format::Av1.has_builtin_encoder()); + assert!(!available(Format::Av1), "nothing registered an encoder for it"); + let (p, seq, m) = matte([0.0, 0.0, 1.0, 1.0], 160, 96, None); + let sc = Scratch::new("av1-missing"); + let err = export(&p, seq, &ExportSettings { path: sc.path("x.mp4"), include_audio: false, ..a.clone() }, &m, &Progress::default()).unwrap_err().to_string(); + assert!(err.contains("AV1") && err.contains("encoder not available"), "{err}"); +} + #[test] fn h265_is_a_format_that_needs_a_registered_encoder() { use std::sync::atomic::{AtomicBool, Ordering}; @@ -496,7 +527,7 @@ fn h265_is_a_format_that_needs_a_registered_encoder() { } assert_eq!(Format::from_name(Format::Hevc.id()), Some(Format::Hevc)); assert_eq!((Format::Hevc.extension(), Format::Hevc.label()), ("mp4", "H.265 (HEVC)")); - assert!(Format::ALL.contains(&Format::Hevc) && Format::ALL.len() == 14); + assert!(Format::ALL.contains(&Format::Hevc) && Format::ALL.len() == 15); let s: ExportSettings = serde_json::from_value(serde_json::json!({"format": "hevc"})).unwrap(); assert_eq!(s.format, Format::Hevc); assert_eq!(serde_json::to_value(&s).unwrap()["format"], "hevc"); diff --git a/crates/isobmff/src/codec.rs b/crates/isobmff/src/codec.rs index ee926cb5..b9afd53e 100644 --- a/crates/isobmff/src/codec.rs +++ b/crates/isobmff/src/codec.rs @@ -1275,6 +1275,10 @@ impl SampleEntry { 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); diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 08f899ec..192dcc03 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3062,6 +3062,7 @@ Go to Sequence End Ir al final de la secuencia Go to Sequence Start Ir al inicio de la secuencia H.264 H.264 + AV1 AV1 H.264 1920×1080 for video sharing sites, VBR 1 pass, 16 Mbps H.264 1920×1080 para sitios de video, VBR 1 pasada, 16 Mbps H.264 1920×1080, VBR 1 pass, 20 Mbps target, AAC 320 kbps H.264 1920×1080, VBR 1 pasada, 20 Mbps objetivo, AAC 320 kbps H.264 1920×1080, VBR 2 pass, 18 Mbps H.264 1920×1080, VBR 2 pasadas, 18 Mbps @@ -3415,6 +3416,7 @@ Main (8-bit; Main 10 for HDR sequences) Main (8 bits; Main 10 para secuencias HDR) Encoder Codificador Hardware (H.265 has no software encoder) Hardware (H.265 no tiene codificador por software) + Hardware (AV1 has no software encoder) Hardware (AV1 no tiene codificador por software) Text Properties Propiedades de texto Text Box Height Altura del cuadro de texto Paragraph Text Texto de párrafo diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 8dbf4419..ae497299 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3057,6 +3057,7 @@ Go to Sequence End シーケンスの終了位置へ移動 Go to Sequence Start シーケンスの開始位置へ移動 H.264 H.264 + AV1 AV1 H.264 1920×1080 for video sharing sites, VBR 1 pass, 16 Mbps 動画共有サイト向けの H.264 1920×1080、VBR 1 パス、16 Mbps H.264 1920×1080, VBR 1 pass, 20 Mbps target, AAC 320 kbps H.264 1920×1080、VBR 1 パス、ターゲット 20 Mbps、AAC 320 kbps H.264 1920×1080, VBR 2 pass, 18 Mbps H.264 1920×1080、VBR 2 パス、18 Mbps @@ -3410,6 +3411,7 @@ Main (8-bit; Main 10 for HDR sequences) メイン (8 ビット、HDR シーケンスではメイン 10) Encoder エンコーダー Hardware (H.265 has no software encoder) ハードウェア (H.265 にはソフトウェアエンコーダーがありません) + Hardware (AV1 has no software encoder) ハードウェア (AV1 にはソフトウェアエンコーダーがありません) Text Properties テキストのプロパティ Text Box Height テキストボックスの高さ Paragraph Text 段落テキスト diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index 731067f9..44919051 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -3050,6 +3050,7 @@ Go to Sequence End 转到序列末尾 Go to Sequence Start 转到序列开头 H.264 H.264 + AV1 AV1 H.264 1920×1080 for video sharing sites, VBR 1 pass, 16 Mbps H.264 1920×1080(视频分享网站),VBR 1 遍,16 Mbps H.264 1920×1080, VBR 1 pass, 20 Mbps target, AAC 320 kbps H.264 1920×1080,VBR 1 遍,目标 20 Mbps,AAC 320 kbps H.264 1920×1080, VBR 2 pass, 18 Mbps H.264 1920×1080,VBR 2 遍,18 Mbps @@ -3403,6 +3404,7 @@ Main (8-bit; Main 10 for HDR sequences) 主要(8 位;HDR 序列使用主要 10) Encoder 编码器 Hardware (H.265 has no software encoder) 硬件(H.265 无软件编码器) + Hardware (AV1 has no software encoder) 硬件(AV1 无软件编码器) Text Properties 文本属性 Text Box Height 文本框高度 Paragraph Text 段落文本 diff --git a/crates/ui-egui/src/panels/export_mode.rs b/crates/ui-egui/src/panels/export_mode.rs index b66ab552..0dc3fbb1 100644 --- a/crates/ui-egui/src/panels/export_mode.rs +++ b/crates/ui-egui/src/panels/export_mode.rs @@ -473,7 +473,7 @@ fn settings_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } if (s.has_audio() || !s.has_video() - || s.format.is_h26x() + || s.format.is_mp4_video() || matches!(s.format, Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg) || s.format.is_mxf()) && !s.is_image_sequence() @@ -521,7 +521,7 @@ fn settings_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { if section(ui, &mut reg, &mut ex.open_sections, "effects", tl!("Effects"), &t) { pick_overlay = effects_section(ui, &mut reg, s, &t); } - if matches!(s.format, Format::H264 | Format::Hevc | Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg) + if matches!(s.format, Format::H264 | Format::Hevc | Format::Av1 | Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg) && section(ui, &mut reg, &mut ex.open_sections, "metadata", tl!("Metadata"), &t) { let m = &mut s.metadata; @@ -612,17 +612,23 @@ fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T }); } match s.video_format() { - f @ (Format::H264 | Format::Hevc) => { - let hevc = f == Format::Hevc; + f @ (Format::H264 | Format::Hevc | Format::Av1) => { + // H.265 and AV1: hardware encoders only (one profile, level chosen by the encoder) + let hevc = f != Format::H264; if hevc { - // the hardware encoder writes Main (8-bit 4:2:0), or Main 10 for HDR sequences where it can; level chosen by the encoder - let label = match (filmcraft_engine::export::hdr_available(Format::Hevc), seq_hdr && !s.sdr) { - (true, true) => tl!("Main 10 (10-bit, HDR)"), - (true, false) => tl!("Main (8-bit; Main 10 for HDR sequences)"), - (false, _) => tl!("Main (8-bit)"), - }; - row(ui, t, tl!("Profile"), |ui| ui.label(label)); - row(ui, t, tl!("Encoder"), |ui| ui.label(tl!("Hardware (H.265 has no software encoder)"))); + if f == Format::Av1 { + row(ui, t, tl!("Profile"), |ui| ui.label(tl!("Main (8-bit)"))); + row(ui, t, tl!("Encoder"), |ui| ui.label(tl!("Hardware (AV1 has no software encoder)"))); + } else { + // the hardware encoder writes Main (8-bit 4:2:0), or Main 10 for HDR sequences where it can; level chosen by the encoder + let label = match (filmcraft_engine::export::hdr_available(Format::Hevc), seq_hdr && !s.sdr) { + (true, true) => tl!("Main 10 (10-bit, HDR)"), + (true, false) => tl!("Main (8-bit; Main 10 for HDR sequences)"), + (false, _) => tl!("Main (8-bit)"), + }; + row(ui, t, tl!("Profile"), |ui| ui.label(label)); + row(ui, t, tl!("Encoder"), |ui| ui.label(tl!("Hardware (H.265 has no software encoder)"))); + } } else { row(ui, t, tl!("Profile"), |ui| { let o = [H264Profile::Baseline, H264Profile::Main, H264Profile::High]; @@ -686,9 +692,9 @@ fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T } }); // The system's hardware encoder where there is one (VideoToolbox on macOS, NVENC on NVIDIA - // GPUs on Windows); everything it does not take (two-pass, HDR, MXF) and every machine - // without one keeps the built-in encoder. - // H.265 has only the hardware encoder: choosing the format is the opt-in. + // GPUs on Windows, VA-API on Linux); everything it does not take (two-pass, HDR, MXF) and + // every machine without one keeps the built-in encoder. + // H.265 and AV1 have only hardware encoders: choosing the format is the opt-in. if !hevc { row(ui, t, "Hardware Encoding", |ui| { let mut on = s.hardware_encoding == HardwareEncoding::Auto; @@ -758,13 +764,12 @@ fn audio_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T } } row(ui, t, tl!("Audio Format"), |ui| { - let fixed = s.format.is_h26x() && s.multiplexer == Multiplexer::Mp4 || audio_only || s.format.is_mxf(); + let fixed = s.format.is_mp4_with(s.multiplexer) || audio_only || s.format.is_mxf(); let cur = match s.audio_codec() { AudioCodec::Aac => "AAC", _ => tl!("Uncompressed (PCM)"), }; - let labels = - vec![("AAC".to_string(), !audio_only), (tl!("Uncompressed (PCM)").to_string(), !(s.format.is_h26x() && s.multiplexer == Multiplexer::Mp4))]; + let labels = vec![("AAC".to_string(), !audio_only), (tl!("Uncompressed (PCM)").to_string(), !s.format.is_mp4_with(s.multiplexer))]; if fixed { ui.label(cur); } else if let Some(i) = combo(ui, reg, "export.audio.codec", cur, &labels, 180.0) { From 758330822f15d9ea77d6da78d1a3bd49889bfd63 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:52:00 +0700 Subject: [PATCH 078/144] tts-text: preserve numeric groups, percentage units and decimal money --- crates/tts-text/src/normalize.rs | 79 +++++++++++++++++++++++++++----- 1 file changed, 67 insertions(+), 12 deletions(-) diff --git a/crates/tts-text/src/normalize.rs b/crates/tts-text/src/normalize.rs index 7b5ee2c1..235d8423 100644 --- a/crates/tts-text/src/normalize.rs +++ b/crates/tts-text/src/normalize.rs @@ -347,7 +347,14 @@ fn parse_amount(rest: &str, _lang: Lang) -> Option<(u64, usize, usize, u32, bool } let major = int_digits.parse::().ok()?; let had_minor = after_decimal && !frac_digits.is_empty(); - let minor = if had_minor { frac_digits.parse::().ok()? } else { 0 }; + // One fractional digit denotes tenths of a major unit (e.g. $1.5 = $1.50), + // not five minor units. Preserve the existing two-digit and longer readings. + let minor = if had_minor { + let value = frac_digits.parse::().ok()?; + if frac_digits.len() == 1 { value * 10 } else { value } + } else { + 0 + }; Some((major, int_digits.len(), used, minor, had_minor)) } @@ -600,14 +607,20 @@ fn has_leading_zero_str(digits: &str) -> bool { /// `%`, an ordinal suffix (`st`/`nd`/`rd`/`th`), a decimal fraction or `-`-joined /// digit groups. +/// Percentage sign attached to a number, or separated from it by whitespace. +fn percent_suffix_len(rest: &str) -> Option { + let suffix = rest.trim_start_matches(char::is_whitespace); + suffix.strip_prefix('%').map(|_| rest.len() - suffix.len() + 1) +} + fn parse_continuation(rest: &str, used: usize, value: u64) -> Option<(Vec, usize)> { let after = rest.get(used..)?; + if let Some(suffix_len) = percent_suffix_len(after) { + let mut words = english_cardinal(value); + words.push("percent".into()); + return Some((words.into_iter().map(Token::Word).collect(), used + suffix_len)); + } match peek(after, 0) { - Some('%') => { - let mut words = english_cardinal(value); - words.push("percent".into()); - Some((words.into_iter().map(Token::Word).collect(), used + 1)) - } Some('s' | 'S' | 't' | 'T' | 'n' | 'N' | 'd' | 'D' | 'r' | 'R') => { let suffix = after.get(..2)?; if is_ordinal_suffix(suffix) && !peek(after, 2).is_some_and(|c| c.is_alphabetic()) { @@ -623,7 +636,12 @@ fn parse_continuation(rest: &str, used: usize, value: u64) -> Option<(Vec return None; } words.extend(frac); - Some((words.into_iter().map(Token::Word).collect(), used + 1 + frac_used)) + let number_end = used + 1 + frac_used; + if let Some(suffix_len) = rest.get(number_end..).and_then(percent_suffix_len) { + words.push("percent".into()); + return Some((words.into_iter().map(Token::Word).collect(), number_end + suffix_len)); + } + Some((words.into_iter().map(Token::Word).collect(), number_end)) } Some('-') => parse_digit_groups(rest, used), _ => None, @@ -700,13 +718,17 @@ fn parse_digit_groups(rest: &str, used: usize) -> Option<(Vec, usize)> { } return Some((words, end)); } - if groups.len() == 2 && groups.iter().all(|g| g.len() <= 3) { + // For shorter non-phone-like groups, retain every group in order. Previously + // three original groups ("5-10-15") dropped the middle one by reading only + // the first and last endpoints. + if groups.iter().all(|g| g.len() <= 3) { let a = rest.get(..used)?.parse::().ok()?; - let b_str = groups.last()?; - let b = b_str.parse::().ok()?; let mut words = english_cardinal(a); - words.push("to".into()); - words.extend(english_cardinal(b)); + for group in &groups { + let n = group.parse::().ok()?; + words.push("to".into()); + words.extend(english_cardinal(n)); + } return Some((words.into_iter().map(Token::Word).collect(), end)); } None @@ -1165,6 +1187,13 @@ mod tests { assert_eq!(words("$3.50", Lang::EnUs), "three dollars and fifty cents"); assert_eq!(words("$1", Lang::EnUs), "one dollar"); assert_eq!(words("$1.00", Lang::EnUs), "one dollar"); + assert_eq!(words("$1.5", Lang::EnUs), "one dollar and fifty cents"); + assert_eq!(words("$2.5", Lang::EnUs), "two dollars and fifty cents"); + assert_eq!(words("$0.5", Lang::EnUs), "zero dollars and fifty cents"); + assert_eq!(words("$1.05", Lang::EnUs), "one dollar and five cents"); + assert_eq!(words("The price is $1.5.", Lang::EnUs), words("The price is $1.50.", Lang::EnUs)); + assert_eq!(words("£1.5", Lang::EnGb), "one pound and fifty pence"); + assert_eq!(words("€1.5", Lang::EnUs), "one euro and fifty cents"); assert_eq!(words("$5.05", Lang::EnUs), "five dollars and five cents"); assert_eq!(words("$0.75", Lang::EnUs), "zero dollars and seventy-five cents"); assert_eq!(words("$1,234.56", Lang::EnUs), "one thousand two hundred thirty-four dollars and fifty-six cents"); @@ -1175,11 +1204,37 @@ mod tests { assert_eq!(words("$0.50", Lang::EnUs), "zero dollars and fifty cents"); } + #[test] + fn short_hyphenated_groups_keep_every_number() { + for lang in [Lang::EnUs, Lang::EnGb] { + assert_eq!(words("1-2-3", lang), "one to two to three"); + assert_eq!(words("5-10-15", lang), "five to ten to fifteen"); + assert_eq!(words("10-20-30", lang), "ten to twenty to thirty"); + assert_eq!(words("1-2-3-4", lang), "one to two to three to four"); + assert_eq!(words("1-2-3-4-5", lang), "one to two to three to four to five"); + assert_eq!(words("1-2-3-4-5-6", lang), "one to two to three to four to five to six"); + assert_eq!(words("2-3", lang), "two to three"); + assert_eq!(words("10-12", lang), "ten to twelve"); + assert_eq!(words("555-0134", lang), "five five five zero one three four"); + assert_eq!(words("005-010-015", lang), "zero zero five zero one zero zero one five"); + } + } + #[test] fn percent() { assert_eq!(words("20%", Lang::EnUs), "twenty percent"); assert_eq!(words("100%", Lang::EnUs), "one hundred percent"); assert_eq!(words("7%", Lang::EnUs), "seven percent"); + for lang in [Lang::EnUs, Lang::EnGb] { + assert_eq!(words("20.5%", lang), "twenty point five percent"); + assert_eq!(words("0.5%", lang), "zero point five percent"); + assert_eq!(words("50 %", lang), "fifty percent"); + assert_eq!(words("50\t%", lang), "fifty percent"); + assert_eq!(words("50\u{a0}%", lang), "fifty percent"); + assert_eq!(words("100 %", lang), "one hundred percent"); + assert_eq!(words("20.5 %", lang), "twenty point five percent"); + assert_eq!(words("20.5%", lang), words("twenty point five percent", lang)); + } } #[test] From 06ebdfa08ab77c7c0ac71e85ff168fde728c9461 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:53:37 +0700 Subject: [PATCH 079/144] interchange: preserve named audio dissolves on EDL import --- crates/interchange/src/edl.rs | 24 +++++++++++++----- crates/interchange/tests/edl.rs | 45 +++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/crates/interchange/src/edl.rs b/crates/interchange/src/edl.rs index 9774c006..ecee0391 100644 --- a/crates/interchange/src/edl.rs +++ b/crates/interchange/src/edl.rs @@ -467,7 +467,10 @@ fn import_event(ctx: &mut Ctx, ev: &Event, report: &mut Report) { let t0 = ctx.rec(l2.tcs[2], df); let t1 = ctx.rec(l2.tcs[3], df); let d = ctx.rate.tick_of(l2.dur); - let effect = transition_for(l2.trans, ev.effect.as_deref(), report); + // Resolve separately for each track kind: named audio dissolves have + // different definitions from their video counterparts. + let mut video_effect = None; + let mut audio_effect = None; let to_black = l2.reel.eq_ignore_ascii_case("BL"); let from_black = l1.reel.eq_ignore_ascii_case("BL"); // Incoming clip. @@ -505,10 +508,8 @@ fn import_event(ctx: &mut Ctx, ev: &Event, report: &mut Report) { continue; } let audio = kind == TrackKind::Audio; - let mut eff = effect.clone(); - if audio && eff.def().is_some_and(|d| d.kind != filmcraft_project::EffectKind::AudioTransition) { - eff = find_effect("constant_power").map(|d| d.instance()).unwrap_or(eff); - } + let cache = if audio { &mut audio_effect } else { &mut video_effect }; + let eff = cache.get_or_insert_with(|| transition_for(l2.trans, ev.effect.as_deref(), audio, report)).clone(); let id = TransitionId(ctx.b.alloc()); let track = &mut ctx.seq.tracks_mut(kind)[idx]; track.transitions.push(Transition { id, effect: eff, start: t0, duration: d, from, to, align, reverse: false }); @@ -564,9 +565,18 @@ fn lay_cut(ctx: &mut Ctx, ev: &Event, l: &Line, video_base: usize, df: bool, m2: ctx.lay(l, item, &name, t0, t1 - t0, src, *m2, video_base); } -fn transition_for(t: Trans, effect_name: Option<&str>, report: &mut Report) -> filmcraft_project::EffectInstance { +fn transition_for(t: Trans, effect_name: Option<&str>, audio: bool, report: &mut Report) -> filmcraft_project::EffectInstance { if let Some(n) = effect_name { - return transition_effect(n, false, report); + let effect = transition_effect(n, audio, report); + if audio && effect.def().is_some_and(|d| d.kind != filmcraft_project::EffectKind::AudioTransition) { + report.warn(format!("transition \"{n}\" is not an audio transition; imported as Constant Power")); + return find_effect("constant_power").map(|d| d.instance()).unwrap_or(effect); + } + return effect; + } + if audio { + // Unnamed EDL audio dissolves default to constant-power fading. + return transition_effect("Constant Power", true, report); } match t { Trans::Wipe(code) => { diff --git a/crates/interchange/tests/edl.rs b/crates/interchange/tests/edl.rs index c1c21475..14100d14 100644 --- a/crates/interchange/tests/edl.rs +++ b/crates/interchange/tests/edl.rs @@ -99,6 +99,51 @@ fn golden_drop_frame_dissolves() { assert!(gm.as_media().unwrap().offline); } +#[test] +fn named_edl_audio_dissolves_keep_their_effects() { + let fixture = "TITLE: AudioDissolve +FCM: NON-DROP FRAME + +001 AX A C 00:00:00:00 00:00:02:00 01:00:00:00 01:00:02:00 +* FROM CLIP NAME: A + +002 AX A C 00:00:02:00 00:00:02:00 01:00:02:00 01:00:02:00 +002 AX A D 024 00:00:02:00 00:00:04:00 01:00:02:00 01:00:04:00 +* TO CLIP NAME: B +"; + let opts = ImportOptions { edl_frame_rate: Some(FrameRate::FPS_24), ..Default::default() }; + for (name, expected, warning) in [ + (Some("CONSTANT GAIN"), "constant_gain", false), + (Some("EXPONENTIAL FADE"), "exponential_fade", false), + (Some("CONSTANT POWER"), "constant_power", false), + (Some("UNRECOGNIZED TRANSITION"), "constant_power", true), + (None, "constant_power", false), + ] { + let text = format!("{fixture}{}", name.map(|name| format!("* EFFECT NAME: {name}\n")).unwrap_or_default()); + let (imp, rep) = import_with(text.as_bytes(), Format::Edl, &opts).unwrap(); + let seq = imp.project.sequence(only_seq(&imp)).unwrap(); + let track = &seq.audio_tracks[0]; + assert_eq!(track.transitions.len(), 1, "{name:?}"); + let tr = &track.transitions[0]; + assert_eq!(tr.effect.effect, expected, "{name:?}: {rep}"); + assert_eq!(tr.start, f(FrameRate::FPS_24, 48), "{name:?}"); + assert_eq!(tr.duration, f(FrameRate::FPS_24, 24), "{name:?}"); + assert_eq!(tr.align, TransitionAlign::StartAtCut, "{name:?}"); + assert!(tr.from.is_some() && tr.to.is_some(), "{name:?}"); + assert_eq!(rep.mentions("not supported"), warning, "{name:?}: {rep}"); + if warning { + assert!(rep.mentions("Constant Power"), "{rep}"); + } + } + + // The same event with video channels retains the video-only Cross Dissolve. + let video = fixture.replace("AX A", "AX V") + "* EFFECT NAME: CROSS DISSOLVE\n"; + let (imp, rep) = import_with(video.as_bytes(), Format::Edl, &opts).unwrap(); + let seq = imp.project.sequence(only_seq(&imp)).unwrap(); + assert_eq!(seq.video_tracks[0].transitions[0].effect.effect, "cross_dissolve"); + assert!(!rep.mentions("not supported"), "{rep}"); +} + #[test] fn explicit_rate_and_wipes_and_keys() { let edl = "TITLE: Keys From c8accc4946a0c7e44b4b17ceb6842fdaa9f53896 Mon Sep 17 00:00:00 2001 From: ReReddo <249398616+ReReddo@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:53:43 +0300 Subject: [PATCH 080/144] settings: the hex colour field takes typing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `#rrggbb` field next to a colour swatch (Appearance ▸ Highlight Color) was refilled from the saved colour on every frame and kept an edit only when the whole text was already a valid colour. One typed or deleted character never is, so the field snapped back and nothing could be typed. While the field has focus it now keeps what is typed, and each valid colour is saved as it comes; leaving the field shows the saved colour. Tests: settings_ui::hex_field_takes_typing_a_character_at_a_time. --- crates/ui-egui/src/panels/settings.rs | 16 +++++++++++++--- crates/ui-egui/tests/settings_ui.rs | 25 +++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 41395d86..e09609cf 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -534,11 +534,21 @@ fn color_field(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, key: if r.changed() { put(&mut d.values, key, json!(settings::hex(rgb))); } - let mut text = choice_text(get(&d.values, key)); + // While the field has focus it shows what is typed, even when that is not a colour yet (one + // character into `#2f6bdf`); each valid colour is kept as it comes. Leaving shows the kept one. + let draft_id = egui::Id::new(("settings-hex", key)); + let mut text = ui.data(|m| m.get_temp::(draft_id)).unwrap_or_else(|| choice_text(get(&d.values, key))); let t = ui.add(egui::TextEdit::singleline(&mut text).desired_width(70.0).font(egui::TextStyle::Monospace)); app.auto.add(&format!("settings.{key}.hex"), t.rect, label); - if t.changed() && settings::parse_hex(&text).is_some() { - put(&mut d.values, key, json!(settings::hex(settings::parse_hex(&text).unwrap_or(rgb)))); + if t.has_focus() { + ui.data_mut(|m| m.insert_temp(draft_id, text.clone())); + } else { + ui.data_mut(|m| m.remove::(draft_id)); + } + if t.changed() + && let Some(c) = settings::parse_hex(&text) + { + put(&mut d.values, key, json!(settings::hex(c))); } } diff --git a/crates/ui-egui/tests/settings_ui.rs b/crates/ui-egui/tests/settings_ui.rs index 3d19aaaf..eca13d50 100644 --- a/crates/ui-egui/tests/settings_ui.rs +++ b/crates/ui-egui/tests/settings_ui.rs @@ -333,6 +333,31 @@ fn appearance_labels_and_tooltips_take_effect() { assert!(d.harness.ctx.global_style().interaction.tooltip_delay < 1.0); } +#[test] +fn hex_field_takes_typing_a_character_at_a_time() { + let mut d = Driver::demo(); + d.menu("app.settings.appearance"); + let hex = |d: &mut Driver| d.draft()["values"]["appearance"]["highlightColor"].clone(); + let before = hex(&mut d); + d.ok("ui.click", json!({"id": "settings.appearance.highlightColor.hex"})); + d.frames(2); + d.ok("ui.key", json!({"key": "Cmd+A"})); + // not a colour yet: the field keeps it and the setting stays + d.ok("ui.type", json!({"text": "#12ab"})); + d.frames(2); + assert_eq!(hex(&mut d), before); + d.ok("ui.type", json!({"text": "34"})); + d.frames(2); + assert_eq!(hex(&mut d), "#12ab34"); + // deleting a character leaves the last colour + d.ok("ui.key", json!({"key": "Backspace"})); + d.frames(2); + assert_eq!(hex(&mut d), "#12ab34"); + d.ok("ui.type", json!({"text": "5"})); + d.frames(2); + assert_eq!(hex(&mut d), "#12ab35"); +} + #[test] fn memory_and_media_cache_pages() { let mut d = Driver::demo(); From 6d6ed3f33a584b7a99a886469cc683183330666d Mon Sep 17 00:00:00 2001 From: impostazioni <25422260+impostazioni@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:55:01 +0100 Subject: [PATCH 081/144] Captions: delete caption tracks from the timeline and Delete Tracks dialog captions.deleteTrack existed but nothing in the UI reached it, so extra caption tracks could not be removed and piled up in the timeline. - Caption track header right-click menu: Delete Caption Track, and Delete Empty Caption Tracks (disabled when none is empty). Automation ids `timeline.captionTrack.C.menu.delete` / `.menu.deleteEmpty`. - sequence.deleteTracks takes `"captions": "empty" | "C2" | id`. Unlike video and audio, every caption track may go (a sequence can have none). Unknown names/ids and other value types are errors. The selection of captions on deleted tracks is cleared; one undo step. - Sequence > Delete Tracks... gets a Caption Tracks section (shown when the sequence has caption tracks), ids `deleteTracks.captions*`. - Spanish strings for the new labels. Tests: engine `delete_tracks_removes_caption_tracks`; headless UI tests for the header menu and the dialog (delete_caption_tracks_ui.rs). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/sequence_tools.rs | 16 ++- crates/engine/src/sequence_tools_tests.rs | 36 +++++ crates/ui-egui/src/i18n/es.tsv | 4 + crates/ui-egui/src/panels/dialogs.rs | 22 ++- .../ui-egui/src/panels/timeline_captions.rs | 17 +++ crates/ui-egui/src/state.rs | 6 +- .../ui-egui/tests/delete_caption_tracks_ui.rs | 131 ++++++++++++++++++ 7 files changed, 222 insertions(+), 10 deletions(-) create mode 100644 crates/ui-egui/tests/delete_caption_tracks_ui.rs diff --git a/crates/engine/src/sequence_tools.rs b/crates/engine/src/sequence_tools.rs index de1e0218..7ec2cf73 100644 --- a/crates/engine/src/sequence_tools.rs +++ b/crates/engine/src/sequence_tools.rs @@ -123,7 +123,7 @@ fn groups() -> Vec<(&'static str, Vec)> { "Delete Tracks…", &["Sequence"], None, - r#"{"video":"empty"|"V2"|id?,"audio":"empty"|"A2"|id?}"#, + r#"{"video":"empty"|"V2"|id?,"audio":"empty"|"A2"|id?,"captions":"empty"|"C2"|id?}"#, has_seq, delete_tracks, )], @@ -717,6 +717,18 @@ fn delete_tracks(s: &mut Session, p: &Value) -> Result { } doomed.extend(mine); } + // caption tracks: unlike video/audio, a sequence may have none, so all of them can go + match p.get("captions") { + None | Some(Value::Null) => {} + Some(v) if v.as_str().is_some_and(|x| matches!(x, "empty" | "allEmpty" | "all-empty")) => { + doomed.extend(q.caption_tracks.iter().filter(|t| t.captions.is_empty()).map(|t| t.id)); + } + Some(v @ (Value::String(_) | Value::Number(_))) => { + let id = crate::captions::track_param(s, &json!({"track": v})).ok_or_else(|| bad("sequence.deleteTracks", "unknown caption track"))?; + doomed.push(id); + } + Some(_) => return Err(bad("sequence.deleteTracks", "`captions` must be \"empty\", a name like \"C2\" or a track id")), + } if doomed.is_empty() { return Err(EngineError::Other("no tracks to delete".into())); } @@ -724,7 +736,9 @@ fn delete_tracks(s: &mut Session, p: &Value) -> Result { s.edit_sequence("Delete Tracks", |q, _, st| { q.video_tracks.retain(|t| !doomed.contains(&t.id)); q.audio_tracks.retain(|t| !doomed.contains(&t.id)); + q.caption_tracks.retain(|t| !doomed.contains(&t.id)); st.selection.retain(|c| q.find_item(*c).is_some()); + st.caption_selection.retain(|c| q.find_caption(*c).is_some()); if let Some(tg) = st.targeting.get_mut(&seq_id) { tg.targeted.retain(|t| !doomed.contains(t)); if tg.video_dest.is_some_and(|t| doomed.contains(&t)) { diff --git a/crates/engine/src/sequence_tools_tests.rs b/crates/engine/src/sequence_tools_tests.rs index aa81758c..3c14de2b 100644 --- a/crates/engine/src/sequence_tools_tests.rs +++ b/crates/engine/src/sequence_tools_tests.rs @@ -315,6 +315,42 @@ fn delete_tracks_empty_and_specific() { assert_eq!(s.active_sequence().unwrap(), &q); } +#[test] +fn delete_tracks_removes_caption_tracks() { + let mut s = demo(); + let before = s.active_sequence().unwrap().clone(); + let n0 = before.caption_tracks.len(); + for _ in 0..3 { + s.execute("captions.newTrack", json!({"format": "Subtitle"})).unwrap(); + } + // new tracks are inserted on top: C1 gets a caption, C2 and C3 stay empty + let cap = s.execute("captions.add", json!({"track": "C1", "text": "Hi", "seconds": 1.0})).unwrap()["caption"].as_u64().unwrap(); + s.execute("captions.select", json!({"captions": [cap]})).unwrap(); + let empty = s.active_sequence().unwrap().caption_tracks.iter().filter(|t| t.captions.is_empty()).count(); + assert!(empty >= 2); + let r = s.execute("sequence.deleteTracks", json!({"captions": "empty"})).unwrap(); + assert_eq!(r["deleted"], json!(empty)); + let q = s.active_sequence().unwrap(); + assert!(q.caption_tracks.iter().all(|t| !t.captions.is_empty())); + assert_eq!(q.video_tracks.len(), before.video_tracks.len(), "video tracks untouched"); + assert!(s.execute("sequence.deleteTracks", json!({"captions": "empty"})).is_err(), "nothing left to delete"); + // unknown names, ids and value types are errors, not panics + for bad in [json!("C99"), json!("C0"), json!("Cx"), json!(999_999), json!(true), json!(["C1"])] { + assert!(s.execute("sequence.deleteTracks", json!({"captions": bad})).is_err(), "{bad}"); + } + // a specific track (with its captions); the last caption track may go too + let left = s.active_sequence().unwrap().caption_tracks.len(); + for _ in 0..left { + s.execute("sequence.deleteTracks", json!({"captions": "C1"})).unwrap(); + } + assert!(s.active_sequence().unwrap().caption_tracks.is_empty()); + assert!(s.state.caption_selection.is_empty(), "selection of deleted captions cleared"); + for _ in 0..left + 1 { + s.execute("edit.undo", json!({})).unwrap(); + } + assert_eq!(s.active_sequence().unwrap().caption_tracks.len(), n0 + 3); +} + #[test] fn split_points_on_the_sequence() { let (mut s, bars) = fresh(); diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index a39dc82e..bff06198 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -407,6 +407,10 @@ Delete Video Tracks Eliminar pistas de video Delete Audio Tracks Eliminar pistas de audio All Empty Tracks Todas las pistas vacías + Caption Tracks Pistas de subtítulos + Delete Caption Tracks Eliminar pistas de subtítulos + Delete Caption Track Eliminar pista de subtítulos + Delete Empty Caption Tracks Eliminar pistas de subtítulos vacías Version {version} Versión {version} A clean-room, pure-Rust non-linear video editor. Part of the ArtCraft family. Un editor de video no lineal en Rust puro, desarrollado en sala limpia. Parte de la familia ArtCraft. Join the ArtCraft Discord Únete al Discord de ArtCraft diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index e82e2c3c..67f33606 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -300,18 +300,19 @@ fn add_tracks(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { keep } -/// Sequence ▸ Delete Tracks…: "Delete Video Tracks" / "Delete Audio Tracks" checkboxes, each with a -/// track choice (All Empty Tracks or one track). Returns whether the dialog stays open. +/// Sequence ▸ Delete Tracks…: "Delete Video Tracks" / "Delete Audio Tracks" (and, when the sequence +/// has caption tracks, "Delete Caption Tracks") checkboxes, each with a track choice (All Empty +/// Tracks or one track). Returns whether the dialog stays open. /// -/// Automation ids: `deleteTracks.video`, `deleteTracks.audio` (checkboxes), -/// `deleteTracks.video.target`, `deleteTracks.audio.target` (track menus), -/// `deleteTracks..option.` (menu entries while open), `deleteTracks.ok`, -/// `deleteTracks.cancel`. +/// Automation ids: `deleteTracks.video`, `deleteTracks.audio`, `deleteTracks.captions` (checkboxes), +/// `deleteTracks..target` (track menus), `deleteTracks..option.` +/// (menu entries while open), `deleteTracks.ok`, `deleteTracks.cancel`. fn delete_tracks(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { let Some(seq) = app.session.active_sequence() else { return false }; let names = |n: usize, p: &str| (1..=n).map(|i| format!("{p}{i}")).collect::>(); let vnames = names(seq.video_tracks.len(), "V"); let anames = names(seq.audio_tracks.len(), "A"); + let cnames = names(seq.caption_tracks.len(), "C"); let mut draft = app.ui.delete_tracks.clone(); let mut keep = true; let mut apply = false; @@ -326,7 +327,11 @@ fn delete_tracks(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { for (kind, title, delete, on, target, list) in [ ("video", tl!("Video Tracks"), tl!("Delete Video Tracks"), &mut draft.video, &mut draft.video_target, &vnames), ("audio", tl!("Audio Tracks"), tl!("Delete Audio Tracks"), &mut draft.audio, &mut draft.audio_target, &anames), + ("captions", tl!("Caption Tracks"), tl!("Delete Caption Tracks"), &mut draft.captions, &mut draft.captions_target, &cnames), ] { + if list.is_empty() { + continue; + } ui.add_space(4.0); ui.label(egui::RichText::new(title).strong()); let label = delete.to_string(); @@ -356,7 +361,7 @@ fn delete_tracks(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { keep = false; } let o = ui.add_enabled( - draft.video || draft.audio, + draft.video || draft.audio || (draft.captions && !cnames.is_empty()), egui::Button::new(egui::RichText::new(tl!("OK")).color(egui::Color32::WHITE)).fill(app.tokens.accent), ); elems.push(("deleteTracks.ok".into(), o.rect, "OK".into())); @@ -379,6 +384,9 @@ fn delete_tracks(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { if draft.audio { p.insert("audio".into(), serde_json::json!(draft.audio_target)); } + if draft.captions && !cnames.is_empty() { + p.insert("captions".into(), serde_json::json!(draft.captions_target)); + } if let Err(e) = app.session.execute("sequence.deleteTracks", serde_json::Value::Object(p)) { app.ui.status = e.to_string(); } diff --git a/crates/ui-egui/src/panels/timeline_captions.rs b/crates/ui-egui/src/panels/timeline_captions.rs index a9a770f6..e13acb06 100644 --- a/crates/ui-egui/src/panels/timeline_captions.rs +++ b/crates/ui-egui/src/panels/timeline_captions.rs @@ -84,6 +84,23 @@ pub fn paint(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, area: Re painter.rect_filled(badge, 3.0, if tr.enabled { FILL } else { t.field_bg }); painter.text(badge.center(), Align2::CENTER_CENTER, &label, Tokens::semibold(10.5), Color32::WHITE); app.auto.add(&format!("timeline.captionTrack.{label}"), badge, &tr.name); + // right-click the header: delete this track or every empty caption track (registered + // before the lock / eye toggles so those stay on top) + let hresp = ui.interact(hrect, egui::Id::new(("cap-header", tr.id.0)), Sense::click()); + hresp.context_menu(|ui| { + for (key, text, cmd, p) in [ + ("delete", tl!("Delete Caption Track"), "captions.deleteTrack", json!({"track": tr.id.0})), + ("deleteEmpty", tl!("Delete Empty Caption Tracks"), "sequence.deleteTracks", json!({"captions": "empty"})), + ] { + let enabled = key != "deleteEmpty" || seq.caption_tracks.iter().any(|t| t.captions.is_empty()); + let r = ui.add_enabled(enabled, egui::Button::new(text)); + app.auto.add(&format!("timeline.captionTrack.{label}.menu.{key}"), r.rect, text); + if r.clicked() { + actions.push((cmd.into(), p)); + ui.close(); + } + } + }); let lock_r = Rect::from_center_size(pos2(hrect.min.x + 49.0, row.center().y), vec2(18.0, 18.0)); let lresp = crate::widgets::icon_toggle( ui, diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 140d60cc..ad7c4ad5 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -799,7 +799,7 @@ impl Default for SequenceSettingsDraft { } /// The Delete Tracks dialog (Sequence ▸ Delete Tracks…): per kind, whether to delete and which -/// track (`"empty"` = All Empty Tracks, or a track name such as `"V2"`). +/// track (`"empty"` = All Empty Tracks, or a track name such as `"V2"` / `"C2"`). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct DeleteTracksDraft { @@ -807,11 +807,13 @@ pub struct DeleteTracksDraft { pub video_target: String, pub audio: bool, pub audio_target: String, + pub captions: bool, + pub captions_target: String, } impl Default for DeleteTracksDraft { fn default() -> Self { - Self { video: false, video_target: "empty".into(), audio: false, audio_target: "empty".into() } + Self { video: false, video_target: "empty".into(), audio: false, audio_target: "empty".into(), captions: false, captions_target: "empty".into() } } } diff --git a/crates/ui-egui/tests/delete_caption_tracks_ui.rs b/crates/ui-egui/tests/delete_caption_tracks_ui.rs new file mode 100644 index 00000000..cd65b134 --- /dev/null +++ b/crates/ui-egui/tests/delete_caption_tracks_ui.rs @@ -0,0 +1,131 @@ +//! Headless UI test of deleting caption tracks: the caption track header's right-click menu +//! (Delete Caption Track / Delete Empty Caption Tracks) and the Caption Tracks section of +//! Sequence ▸ Delete Tracks…. + +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); + let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.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 ids(&mut self, prefix: &str) -> Vec { + let v = self.ok("ui.elements", json!({"prefix": prefix})); + v.as_array().unwrap().iter().filter_map(|e| e["id"].as_str().map(str::to_string)).collect() + } + + fn exec(&mut self, cmd: &str, params: Value) -> Value { + self.ok("engine.execute", json!({"command": cmd, "params": params})) + } + + /// Caption track names top first, with their caption counts. + fn caption_tracks(&mut self) -> Vec<(String, usize)> { + let r = self.exec("captions.list", json!({})); + r["tracks"].as_array().unwrap().iter().map(|t| (t["name"].as_str().unwrap().to_string(), t["captions"].as_array().unwrap().len())).collect() + } + + fn right_click_menu(&mut self, track: &str, item: &str) { + self.ok("ui.click", json!({"id": format!("timeline.captionTrack.{track}"), "button": "right"})); + self.frames(3); + self.ok("ui.click", json!({"id": format!("timeline.captionTrack.{track}.menu.{item}")})); + self.frames(3); + } +} + +fn setup() -> Driver { + let mut d = Driver::demo(); + // start from no caption tracks, then add four (each goes on top): Keep (with a caption), E1–E3 empty + while !d.caption_tracks().is_empty() { + d.exec("captions.deleteTrack", json!({"track": "C1"})); + } + for name in ["E3", "E2", "Keep", "E1"] { + d.exec("captions.newTrack", json!({"format": "Subtitle", "name": name})); + } + d.exec("captions.add", json!({"track": "C2", "text": "Hello", "seconds": 1.0})); + d.frames(3); + assert_eq!(d.caption_tracks(), [("E1".into(), 0), ("Keep".into(), 1), ("E2".into(), 0), ("E3".into(), 0)]); + d +} + +#[test] +fn header_menu_deletes_one_or_all_empty_caption_tracks() { + let mut d = setup(); + d.right_click_menu("C1", "delete"); + assert_eq!(d.caption_tracks(), [("Keep".into(), 1), ("E2".into(), 0), ("E3".into(), 0)]); + // from the non-empty track's menu: only the empty ones go + d.right_click_menu("C1", "deleteEmpty"); + assert_eq!(d.caption_tracks(), [("Keep".into(), 1)]); + // the last caption track can be deleted too, captions and all; undo brings it back + d.right_click_menu("C1", "delete"); + assert!(d.caption_tracks().is_empty()); + assert!(d.ids("timeline.captionTrack.").is_empty(), "no caption rows left"); + d.exec("edit.undo", json!({})); + d.frames(3); + assert_eq!(d.caption_tracks(), [("Keep".into(), 1)]); +} + +#[test] +fn delete_tracks_dialog_has_a_caption_section() { + let mut d = setup(); + d.ok("ui.menu.invoke", json!({"command": "sequence.deleteTracks", "params": {}})); + d.frames(3); + d.ok("ui.click", json!({"id": "deleteTracks.captions"})); + d.frames(2); + // one specific track: C3 (E2) + d.ok("ui.click", json!({"id": "deleteTracks.captions.target"})); + d.frames(3); + d.ok("ui.click", json!({"id": "deleteTracks.captions.option.C3"})); + d.frames(2); + d.ok("ui.click", json!({"id": "deleteTracks.ok"})); + d.frames(3); + assert_eq!(d.caption_tracks(), [("E1".into(), 0), ("Keep".into(), 1), ("E3".into(), 0)]); + // All Empty Tracks (the default choice) + d.ok("ui.menu.invoke", json!({"command": "sequence.deleteTracks", "params": {}})); + d.frames(3); + d.ok("ui.click", json!({"id": "deleteTracks.captions"})); + d.frames(2); + d.ok("ui.click", json!({"id": "deleteTracks.ok"})); + d.frames(3); + assert_eq!(d.caption_tracks(), [("Keep".into(), 1)]); +} From 30417a345d7a0556ac518acf7f8a357c7420e4bb Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:56:04 +0700 Subject: [PATCH 082/144] settings: reflect unwired Timeline focus preference in schema --- crates/engine/src/settings.rs | 2 +- crates/engine/src/settings_tests.rs | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/settings.rs b/crates/engine/src/settings.rs index 2f020835..68271518 100644 --- a/crates/engine/src/settings.rs +++ b/crates/engine/src/settings.rs @@ -1074,7 +1074,7 @@ static CATEGORIES: &[Category] = &[ f("timeline.multichannelMonoMediaTracks", "Multichannel Mono Media", Kind::Choice(TRACKS), false), ], ), - b("timeline.focusTimelineOnEdit", "Set focus on the Timeline when performing Insert/Overwrite edits", true), + b("timeline.focusTimelineOnEdit", "Set focus on the Timeline when performing Insert/Overwrite edits", false), b("timeline.snapPlayhead", "Snap playhead in Timeline when Snap is enabled", true), b("timeline.returnToBeginning", "At playback end, return to beginning when restarting playback", true), b("timeline.outOfSyncUnlinked", "Display out of sync indicators for unlinked clips", false), diff --git a/crates/engine/src/settings_tests.rs b/crates/engine/src/settings_tests.rs index 241f36f6..50a3cf3a 100644 --- a/crates/engine/src/settings_tests.rs +++ b/crates/engine/src/settings_tests.rs @@ -251,6 +251,18 @@ fn schema_command_describes_every_category() { assert_eq!(auto["value"], "pageScroll"); assert_eq!(auto["wired"], true); assert!(auto["kind"]["choices"].as_array().unwrap().iter().any(|c| c["value"] == "smoothScroll")); + + // This option is stored, but Insert/Overwrite does not yet implement focus transfer. + let focus = tl["fields"].as_array().unwrap().iter().find(|f| f["key"] == "timeline.focusTimelineOnEdit").unwrap(); + assert_eq!(focus["kind"], json!({"type": "bool"})); + assert_eq!(focus["wired"], false); + assert_eq!(focus["value"], false); + set(&mut s, "timeline.focusTimelineOnEdit", json!(true)); + let updated = s.execute("prefs.schema", json!({"category": "timeline"})).unwrap(); + let focus = updated["categories"][0]["fields"].as_array().unwrap().iter().find(|f| f["key"] == "timeline.focusTimelineOnEdit").unwrap(); + assert_eq!(focus["value"], true); + assert_eq!(focus["wired"], false); + let one = s.execute("prefs.schema", json!({"category": "trim"})).unwrap(); assert_eq!(one["categories"].as_array().unwrap().len(), 1); assert!(s.execute("prefs.schema", json!({"category": "nope"})).is_err()); From 3ad5a593217410ed687abfaaf76b444aece0107d Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 14:09:14 -0400 Subject: [PATCH 083/144] Progress docs: target-app parity, gaps, alpha gate, readiness by audience (#700) Progress docs per craftrules standards/progress-docs.md: target-app parity, gaps, roadmap with alpha gate, readiness by audience (full / mainstream / essentials) with Opus 5.5 hours, and feature, format, hardware, UI and localization parity docs. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 1 + CLAUDE.md | 2 +- README.md | 11 +- ROADMAP.md | 246 ++++++++++--------------- docs/agents.md | 19 +- docs/architecture.md | 10 +- docs/codec-parity.md | 63 +++++++ docs/contributing.md | 2 +- docs/file-format-parity.md | 94 ++++++++++ docs/gaps.md | 240 ++++++++++++++++++++++++ docs/hardware-parity.md | 87 +++++++++ docs/localization-parity.md | 65 +++++++ docs/roadmap.md | 91 +++++++++ docs/target-app-parity.md | 358 ++++++++++++++++++++++++++++++++++++ docs/ui-parity.md | 38 ++++ 15 files changed, 1165 insertions(+), 162 deletions(-) create mode 100644 docs/codec-parity.md create mode 100644 docs/file-format-parity.md create mode 100644 docs/gaps.md create mode 100644 docs/hardware-parity.md create mode 100644 docs/localization-parity.md create mode 100644 docs/roadmap.md create mode 100644 docs/target-app-parity.md create mode 100644 docs/ui-parity.md diff --git a/AGENTS.md b/AGENTS.md index f616a9f8..0683cb87 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -142,6 +142,7 @@ as PhotoCraft does with `cargo xtask corpus`. - [docs/agents.md](docs/agents.md): driving FilmCraft over MCP / the control channel, and the agent work loop - [docs/control-protocol.md](docs/control-protocol.md): control-channel method reference - [ATTRIBUTION.md](ATTRIBUTION.md): asset index +- [ROADMAP.md](ROADMAP.md), [docs/target-app-parity.md](docs/target-app-parity.md), [docs/gaps.md](docs/gaps.md): stage, parity with Premiere Pro and the ranked work list (craftrules `standards/progress-docs.md`) ## Contributor credits (About window) diff --git a/CLAUDE.md b/CLAUDE.md index 9779b45c..2ecdacc2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ FilmCraft is a clean-room, open-source, pure-Rust non-linear video editor target ## Start every session here `plan/` is maintainer-local (gitignored, not in the repo). Each step names the public equivalent to use if you don't have it. -1. Read `plan/STATUS.md` (current milestone, next unchecked task, blockers). Public: [`ROADMAP.md`](ROADMAP.md). +1. Read `plan/STATUS.md` (current milestone, next unchecked task, blockers). Public: [`ROADMAP.md`](ROADMAP.md), then [`docs/gaps.md`](docs/gaps.md) (ranked shortfalls) and [`docs/roadmap.md`](docs/roadmap.md) (current focus). 2. Read the task in `plan/execution-plan.md` §3, the relevant section of `plan/architecture.md`, and the README/docs of the crate you touch. Public: [`docs/architecture.md`](docs/architecture.md), [`docs/contributing.md`](docs/contributing.md), [`docs/testing.md`](docs/testing.md). Visual/behaviour reference: `plan/premiere/` (local only, never committed). 3. Follow the autonomous operation protocol (`plan/execution-plan.md` §7; public summary: [`docs/agents.md`](docs/agents.md) §5–7). Don't stop to ask unless it lists the decision as the user's. diff --git a/README.md b/README.md index fc1acfeb..e74820ab 100644 --- a/README.md +++ b/README.md @@ -274,20 +274,23 @@ The control protocol is documented in [docs/control-protocol.md](docs/control-pr | [docs/control-protocol.md](docs/control-protocol.md) | Control-channel and MCP reference | | [docs/project-files.md](docs/project-files.md) | `.fcproj` format, schema migrations, auto-save and crash recovery | | [docs/graphics.md](docs/graphics.md) · [docs/captions.md](docs/captions.md) | Text engine, graphic clips and tools; caption tracks and formats | -| [ROADMAP.md](ROADMAP.md) | Honest assessment, what's missing, milestones and estimates | +| [ROADMAP.md](ROADMAP.md) | Stage, parity numbers, languages, what's next and the progress log | +| [docs/target-app-parity.md](docs/target-app-parity.md) · [docs/gaps.md](docs/gaps.md) · [docs/roadmap.md](docs/roadmap.md) | Parity assessment against Premiere Pro, the ranked list of shortfalls, milestones and current focus | +| [docs/codec-parity.md](docs/codec-parity.md) · [docs/file-format-parity.md](docs/file-format-parity.md) · [docs/hardware-parity.md](docs/hardware-parity.md) · [docs/ui-parity.md](docs/ui-parity.md) · [docs/localization-parity.md](docs/localization-parity.md) | Parity checklists by dimension | ## Status FilmCraft is young and moving fast. Editing, trimming, multicam, colour, keyframes, effects, titles, captions, mixing, codecs and export work today. -We track two numbers ([ROADMAP.md](ROADMAP.md#honest-assessment-2026-10-05)): +FilmCraft is in **alpha**. We track two numbers ([docs/target-app-parity.md](docs/target-app-parity.md), re-measured 2026-10-10 against Premiere Pro 26.5.2): -- **Feature checklist: ~87%.** Premiere Pro's menu items, effects, transitions, panels and formats that exist in FilmCraft. -- **Ready for real work: ~50–60%.** Our honest estimate of how close FilmCraft is to replacing Premiere on real projects. +- **Feature breadth: ~86%.** Premiere Pro's menu items, effects, transitions, panels and formats that exist in FilmCraft (menus 92% and effects 100% measured). +- **Ready for real work: ~56%.** Our honest estimate of how close FilmCraft is to replacing Premiere on real projects. The biggest gaps today: - **Speed on big footage.** Hardware decoding works on macOS and Windows, and for H.264 and HEVC on Linux (VA-API, or NVDEC with NVIDIA's driver; VP9 and AV1 decode in software there). Blend modes and the most common effects run on the GPU, but Lumetri, keys and export rendering still run on the CPU; H.264 encoding can use the hardware encoder on macOS and on Windows and Linux with an NVIDIA GPU (opt-in, Export ▸ Hardware encoding), and H.265 export is hardware-only on both (HDR sequences export Main 10 PQ / HLG with NVENC; VideoToolbox writes 8-bit SDR) ([#30](https://github.com/storytold/filmcraft/issues/30)). +- **Premiere projects.** `.prproj` files can't be opened yet; bring sequences over as FCP7 XML, AAF or OTIO. - **No plugins.** No VST3 / Audio Units or OpenFX hosting. - **Delivery codecs.** H.264 is our only software delivery-codec export; H.265 exports only through a hardware encoder (NVIDIA on Windows: Main 8-bit SDR and Main 10 HDR PQ / HLG; VideoToolbox on macOS: 8-bit Main, SDR); no AV1 export yet. - **Real-world media and platforms.** Our decoders are bit-exact on conformance streams, but camera and phone files in the wild are less tested. Windows and Linux get far less testing than macOS. diff --git a/ROADMAP.md b/ROADMAP.md index e22211db..e5d94acf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,164 +1,111 @@ # FilmCraft Roadmap -Progress toward parity with Adobe Premiere Pro, with estimates. Updated as milestones land. +**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 updated:** 2026-10-05 · **Feature checklist:** ~87% · **Ready for real work:** ~50–60% · **Code:** 35 crates, 1605+ tests +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (full re-measure against Premiere Pro 2026 26.5.2; scorecard, gaps, milestones and localization moved into docs/ per the craftrules progress-docs standard) · **Target:** Adobe Premiere Pro 2026 (26.5.2) -Two numbers, because they answer different questions. The **feature checklist** counts whether -Premiere's menu items, effects, panels and formats exist in FilmCraft. **Ready for real work** is -our honest estimate of how close FilmCraft is to replacing Premiere for an editor on real -projects: depth, correctness, robustness, speed and the pro ecosystem. The gap between the two -is the work that matters most now. Agents: read [Where we are lacking](#where-we-are-lacking) -before choosing work. +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), +[docs/gaps.md](docs/gaps.md) (the ranked work list: **agents, read it before choosing work**) and +[docs/roadmap.md](docs/roadmap.md) (milestones and current focus). -## Honest assessment (2026-10-05) +## Headline numbers -### How the checklist number is made, and what it doesn't tell you - -- **Partly measured.** The menu figure comes from diffing a dump of Premiere 26.5's menus against - `filmcraft-cli commands`. The effects, transitions and audio-effects counts are checked by unit - tests against Premiere's Effects panel tree (`premiere_26_video_effects_catalogue`, - `premiere_audio_set_is_complete_and_foldered`, `premiere_26_transition_tree`). -- **Partly agent-reported.** The other areas' percentages are written by the agents doing the work. - No automated parity check runs in CI (PhotoCraft has `cargo xtask parity`; we don't yet), so - nothing stops a number drifting upward. -- **Presence, not quality.** An item counts once it exists. An effect described as an - "approximation" counts the same as an exact one. Nothing compares our rendering with - Premiere's. -- **Weights understate what users hit first.** Performance carries 5%. Plugins and AI features - aren't in the scorecard at all. - -### By dimension - -| Dimension | Checklist says | Honest estimate | Evidence | -|---|---|---|---| -| **Feature breadth** (menus, commands, panels) | ~86–95% | **~85%** | Menu diff against Premiere 26.5; effect, transition and audio-effect catalogues complete by test. The long tail is mostly there. | -| **Correctness in depth** | not tracked | **~60–70%** | A first-time contributor found four real bugs in core paths within hours (#5–#8: AIFF import failed, Slide left linked audio behind, transitions didn't follow ripple trims, export ignored start/end times). User reports: Color Matte always grey with no picker (#29); ALSA audio error on Linux (#23). Settings are "most wired", not all. | -| **Codecs and media** | ~90% | **~75%** | Our decoders are bit-exact on conformance streams. Real camera and phone media (variable frame rate, damaged files, unusual containers) is far less tested. No camera RAW (RED, BRAW, ARRIRAW), no E-AC-3. | -| **Export** | ~92% | **~70%** | Delivery: H.264, plus H.265 through a hardware encoder only (NVENC on Windows: Main 8-bit SDR and Main 10 HDR PQ / HLG; VideoToolbox on macOS: Main 8-bit SDR); ProRes, DNxHR and image sequences cover mastering. No AV1 export, no 10-bit / HDR HEVC on macOS. AAF and OMF have never been validated in Avid Media Composer or Pro Tools. Export renders and encodes on the CPU only. | -| **Performance and hardware** | ~62% | **~35–40%** | Premiere runs effects, decoding and encoding on the GPU and the hardware media engines. Here, hardware decode works on macOS (VideoToolbox, #33; active in the desktop app only since #41, so releases up to 0.2.1 decoded in software) and Windows (Media Foundation / Direct3D 11 DXVA, H.264, HEVC Main / Main 10, VP9 and AV1 4:2:0 8/10-bit, #30), and for H.264 and HEVC on Linux (VA-API: 4K H.264 at 11 ms of CPU per frame instead of 417 ms, 4K HEVC at 10 ms instead of 203 ms). The GPU does compositing, blend modes (#32) and 33 common effects (GPU2, GPU4, GPU5): colour adjustments, crop/flip/transform, blurs, sharpen, vignette, video limiter. Lumetri, keys, masks and the remaining effects are CPU, and so is export rendering unless Export ▸ GPU Rendering is turned on (opt-in); H.264 encoding can use VideoToolbox on macOS (opt-in per export, no B-frames) and NVENC on Windows and Linux with an NVIDIA GPU (opt-in per export), and H.265 (HEVC) export exists on macOS and Windows only, through the same hardware encoders. Linux decodes H.264 and HEVC in hardware (VP9 and AV1 there are software); its only hardware encoder is NVENC H.264. 8K isn't real-time (AV1 is on Windows with a hardware decoder). The effect list shows Premiere's "GPU accelerated" badge, but only 33 effect ids run on the GPU here; the rest, Lumetri included, run on the CPU. | -| **Stability** | not tracked | **improving, unproven** | The never-crash pass (no panics in product code, last-resort guards) landed on 2026-10-04. There's no field record from real users' projects yet. | -| **Plugins and pro ecosystem** | not tracked | **~0–10%** | No audio plugin hosting (VST3 / Audio Units) and no third-party video effects (OpenFX). For many professional editors this alone rules FilmCraft out. Team Projects and Productions are out of scope by design. | -| **AI features** | partial | **~25–35%** | Speech to text exists but is off in default builds (the `whisper` feature). Enhance Speech is a DSP chain, not a model. Auto Reframe is approximate. No Generative Extend, and no media-intelligence search or auto colour. | -| **Platforms** | not tracked | **macOS solid; Windows and Linux thin** | Development and testing happen on macOS. Windows and Linux are built for releases but barely exercised at runtime. The web build is a compile target; the browser app shell isn't finished. | -| **UI fidelity** | ~85% | **~80%** | Agents compare with Premiere reference screenshots; this hasn't been checked independently. | - -### Where we are lacking - -In priority order. Agents should prefer this work over adding more checklist items. - -1. **Hardware acceleration** (#30), the most visible gap to users: - - hardware decode on Linux (VA-API): H.264 and HEVC are in; VP9 and AV1 remain; - - zero-copy decoded frames into wgpu; - - the remaining effects on the GPU: Lumetri, keys, masks (33 common effects are done); - - GPU export, then hardware encode: H.264 and H.265 on macOS are in (H.264 opt-in; H.265 has no other encoder), H.264 and H.265 (Main 8-bit, and Main 10 HDR PQ / HLG) on NVENC (Windows); Main 10 / HDR HEVC on VideoToolbox, Linux and other Windows vendors remain. -2. **A measured parity number.** Add `cargo xtask parity`, run in CI. It should cover menus, - commands, effects, transitions, panels, preferences and formats against the Premiere snapshot in - `plan/premiere/`. It should report presence and fidelity separately (exact, approximate, stub). -3. **Real-world media corpus.** Phone, camera and screen recordings, variable frame rate, damaged - files, long GOPs, odd containers. Each gets an import → edit → export round-trip test with an - ffprobe / ffmpeg oracle. -4. **Workflow acceptance tests.** Scripted MCP sessions that do real jobs end to end: assemble and - trim a scene, colour it, mix and loudness-normalise it, caption it, deliver it, round-trip - through XML / AAF. -5. **Windows and Linux at runtime.** CI that runs the test suite and a headless playback/export - smoke test on both, not just release builds. Fix the Linux audio path (#23). -6. **Delivery codecs.** HEVC export is in on macOS (VideoToolbox, 8-bit, no software encoder) and on Windows (NVENC, 8-bit and Main 10 HDR); still to do: HEVC elsewhere, 10-bit / HDR HEVC on macOS, and AV1 (`rav1e`, BSD-2). -7. **Plugin hosting** (VST3 / Audio Units, then OpenFX). Needs FFI, so it falls under the isolated - `unsafe` crate rule (AGENTS.md §0.3). **Owner decision needed** before starting. -8. **AI features** with openly licensed local models: transcription on by default, text-based - editing, speech enhancement, reframing. -9. **The web app shell**: file access, WebCodecs, audio. - -### Where we're going - -**Next:** close items 1–5. Hardware acceleration fixes the gap users notice first (CPU-bound 4K playback and export). The -parity tool, media corpus and workflow tests make the progress number trustworthy. Windows and -Linux CI makes "runs everywhere" true. - -**After that:** delivery codecs, plugin hosting (pending the owner's decision) and AI features, -measured with the same tools. - -**Goal:** "ready for real work" at 85%+, measured rather than estimated, on all three desktop -platforms. - -## Feature checklist scorecard - -Measured against Premiere Pro 26.5 on this machine. Menu items: the native menu bar dump, minus -Adobe-cloud-only items (Team Projects, Productions, Firefly, Stock, Dynamic Link, account/help pages), -matched against `filmcraft-cli commands` and the UI command table. Effects: the Effects panel tree. - -| Area | Weight | Measured | Coverage | -|---|---|---|---| -| Editing, timeline, trimming, multicam | 20% | core edit algebra, trim modes, sync, multicam, menu long tail, Scene Edit Detection, Automate to Sequence, Premiere default keyboard (143 new commands; 39 cloud/absent-panel keys skipped) | ~86% | -| Menus / commands | (cross-check) | ~325 of 344 in-scope menu items | ~95% | -| Effects and transitions | 12% | video effects 93/93 (+Legacy, Obsolete), audio effects 53/53, video transitions 84/84 (+21 Legacy), audio transitions 3/3; some approximations (Warp Stabilizer 2-D, Morph Cut, Auto Reframe) | ~92% | -| Media I/O and codecs | 12% | H.264, HEVC, VP9, AV1, ProRes, DNx, MJPEG, MPEG-2/MPEG-1 (4:2:2 incl. IMX/XDCAM), Opus, AAC, AC-3, MP2; MP4/MOV/MKV/WebM, MXF (incl. MPEG-2), MPEG TS/PS (AVCHD, broadcast, DVD), Ogg, image sequences, BWF; no E-AC-3, camera raw, HW decode | ~90% | -| Panels and UI fidelity | 12% | all main panels incl. Metadata, Timecode, Events, Progress, Reference Monitor, full Lumetri Scopes, audio effect editors; Project panel List/Icon/Freeform with view presets and hover scrub; Media Browser with Favorites/navigation/ingest | ~85% | -| Audio | 10% | mixer, automation (track + clip), Essential Sound, meters, all 53 effects, 5.1 tracks/buses/panner/export, voice-over record, Remix | ~88% | -| Colour | 8% | Lumetri complete, LUTs, colour management, HDR, all Lumetri Scopes (+ `scopes.read` for agents) | ~88% | -| Graphics and captions | 8% | text engine, shapes, per-character styles, our own graphics templates (.fcgt, 8 built-ins, export/install/edit), rolls/crawls, responsive pins + time, captions SRT/VTT/SCC/MCC/STL/TTML/DFXP, transcripts | ~85% | -| Export | 8% | own H.264/AAC, ProRes, DNxHR, MXF OP1a/OP-Atom, image sequences, GIF, WAV, 5.1; preset library + user presets, full Export-mode settings, queue, Quick Export; AAF (Edit Protocol) import/export, OMF 2.0 export; no AAF/OMF validation against Avid/Pro Tools yet | ~92% | -| Preferences and project management | 5% | Settings dialog with 16 categories (most settings wired), project settings, scratch disks, search bins, templates | ~80% | -| Performance | 5% | 1080p real-time, 3×1080p; 4K H.264 real-time at load ≤140 (−23% decoder cycles, opt-in draft decoding at 1/2–1/4); HEVC 2.5× cheaper, catch-up decoding, `cargo xtask bench` + `perf.stats`; blend modes on the GPU (#32); 8K and AV1 not yet real-time on a loaded machine; H.264 and HEVC decode in hardware on Linux (VA-API), and on Windows only decode and opt-in NVENC H.264 export (see the honest assessment: ~35–40% against Premiere) | ~62% | -| **Weighted total** | | | **~87%** | - -## Estimate to parity - -This estimate is for the **feature checklist**. Closing the honest-assessment gaps (above) is the -"Robust on real-world material" row, plus plugins and AI features, which it doesn't include. - -Measured throughput in the last work block (2026-10-01 night → 10-02): five Opus 5.5 agents in -parallel for ~4.5 wall-clock hours (~18 agent-hours including integration) moved parity by ~4 points -(≈4–5 agent-hours per point), on a machine that was heavily overloaded (load 150–300 on 14 cores) and -once ran out of disk. The remaining points are a long tail (each effect, dialog and preference page is -small, but there are many), so the estimate applies a 1.3–1.5× tail factor. - -| | Opus 5.5 agent-hours | Wall-clock (4–5 parallel agents + integrator) | +| | Value | How | |---|---|---| -| Feature parity by checklist (~19 points left) | ~90–125 | **~20–32 h** | -| Robust on real-world material (codec edge cases, 4K/8K performance, pro workflows) | +120–200 | **+30–50 h** | -| **Total to "100% and better"** | **~210–325** | **~50–82 h (≈2–3.5 days, 24/7)** | +| **Feature breadth** | **~86%** | Does each Premiere feature exist? Menus 92% (measured: label diff against Premiere 26.5.2's menu dump), effects / transitions / audio effects 100% (measured by tests), other areas estimated | +| **Ready for real work** | **~56%** (51–61%) | How close FilmCraft is to replacing Premiere for an editor on real projects: depth, correctness, file compatibility, speed, stability, ecosystem. Estimated depth per area, combined by a [written formula](docs/target-app-parity.md#three-readiness-numbers) | +| **Mainstream practitioner** | **~55%** | A typical professional editor's weekly work only, discounted ×0.90 interaction, ×0.92 stability, ×0.88 file exchange with Premiere users ([method](docs/target-app-parity.md#mainstream-practitioner)) | +| **Essentials user** | **~60%** | Import, cut, title, music, export at default settings, discounted ×0.88 launch / stability, ×0.90 discoverability, ×0.95 opening files people send ([method](docs/target-app-parity.md#essentials-user)) | +| **To beta** | **~400–700 Opus 5.5 agent-hours** | Estimated, calibrated from this repo's PR history | +| **To full parity** | **~1,550–2,750 agent-hours** | ~70% parallelizes; plugins, AI models, native-speaker review and some hardware need a human | +| Code (measured) | 45 crates, ~343k lines of Rust, 2,520 tests, 268 merged PRs, 69 authors | `git`, `gh`, `grep` | -The 2026-10-02 block moved parity ~62% → ~72% with six agents in ~6 wall-clock hours, despite the -disk filling twice; the remaining work is mostly panels (scopes, Metadata, Media Browser), export -presets/queue, codecs (MXF, image sequences, hardware decode), performance and graphics templates. +Two numbers, because they answer different questions. The gap between them is the work that +matters most now: breadth is nearly done, depth is not. -Limits on speed: CPU and disk on one machine (more than ~5 agents slows everyone down: each worktree -build is 6–7 GB and a full `cargo xtask ci` takes 20–60 min under load), and a single integrator -merging, resolving conflicts (e.g. two agents bumping the project schema) and re-running CI. With one -agent and no parallelism, multiply wall-clock by ~3–4. +### Readiness by audience -Not reachable clean-room and locally: **Generative Extend** (needs a large video-generation model). -**Enhance Speech** and **Auto Reframe** are feasible only with openly licensed models we can ship. +| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates | +|---|---|---|---| +| Full target (ready for real work) | ~56% | ~1,450–2,600 (~70% parallelizes) | Plugin hosting, AI models, localization incl. RTL / Devanagari, camera RAW and delivery codecs, `.prproj`, hardware acceleration, UI fidelity | +| Mainstream practitioner | ~55% | ~500–850 (~65% parallelizes) | `.prproj` import (60–120), UI fidelity (120–200), stability and PR CI (40–80), 10-bit / 4:2:2 camera decode (30–50), playback / hardware (60–100), import and export gaps (60–100) | +| Essentials user | ~60% | ~180–350 (~60% parallelizes) | Launch crashes on Windows / Intel / older macOS (40–80), discoverability and timeline friction (60–110), playback lag (30–60), common import gaps (20–40), audio devices (15–30) | -## Milestones +Method and calibration: [docs/target-app-parity.md](docs/target-app-parity.md#three-readiness-numbers). -Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining agent-hours. +## By dimension -| # | Milestone | Status | Done | Remaining | Est. | -|---|---|---|---|---|---| -| 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 | -| 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) | Effects on the GPU (in progress, #30), GPU masks in the live path, GPU export, exact Warp Stabilizer (3-D) and Morph Cut | 20–30 | -| M6 | Export | ✅ | Own H.264 encoder (High/Main/Baseline, B-frames, VBR/CBR/2-pass) → MP4 or QuickTime + own AAC; ProRes, DNxHR, MJPEG, PNG/TIFF/BMP sequences (Premiere-style numbering), GIF, WAV, AIFF; background jobs; M6.5: Export mode parity (frame size / rate / scaling / pixel aspect, profile / level, CBR / VBR 1- and 2-pass, keyframe distance, audio codec / rate / channels / bitrate / sample size, multiplexer, burn-in or sidecar captions, image / name / timecode overlays, video limiter, loudness normalization with true-peak limiter, metadata, ranges, estimated size, summary), 24 built-in presets + user presets with favourites and import/export (Preset Manager), export queue (reorder, cancel, retry, several sequences / ranges), Quick Export; `export.*` commands and `filmcraft-cli export --preset` | Interlaced encoding, 5.1 audio, smart render, publishing destinations | 6–8 | -| M7 | Audio | 🟡 | Mixer graph (tracks → submixes → Mix, pre/post-fader inserts and sends, latency-compensated, sample-accurate, ~6× realtime for 24 tracks × 3 effects on one core), Audio Track Mixer + Audio Clip Mixer panels, track automation (Off/Read/Latch/Touch/Write, recorded live while playing, thinned to keyframes, timeline lanes with pen editing), solo/solo-safe, channel mapping basics, peak + BS.1770 loudness meters (match ffmpeg), DSP crate with 16 clip/track effects, Audio Gain (set/adjust/normalize), Constant Power / Constant Gain / Exponential Fade, Essential Sound (types, Loudness auto-match, Repair incl. DeEss/DeReverb, Clarity, Creative, Ducking keyframes, presets; full Dialogue chain 22× realtime) | Music duration remix, ML speech enhancement, 5.1 panner and multichannel buses, voice-over record, effect editor windows (EQ curve), remaining effects (multiband, convolution reverb), clip-mixer automation recording | 6–9 | -| M8 | Colour | 🟡 | Lumetri: basic, creative + looks, RGB & hue curves, wheels, HSL secondary, vignette, section bypass; Input LUT / Look LUT (.cube 1D/3D/shaper, .3dl; tetrahedral CPU + WGSL; project LUT library; built-in camera conversions); Colour Match (Oklab tonal-range statistics, skin protection, solved in Lumetri wheels); colour management: Rec. 709 / Rec. 2100 PQ / HLG working spaces + wide gamut, Interpret Footage colour space (S-Log3, V-Log, Canon Log 2/3, LogC3/4, Apple Log, D-Log from published specs), metadata auto-detect, BT.2390 tone mapping, gamut mapping, HDR export signalling (VUI/colr/mdcv/clli/SEI, ffprobe-verified); Lumetri Scopes: Vectorscope YUV (75/100 % targets, skin-tone line) and HLS, Histogram, Parade (RGB/YUV/RGB-White), Waveform (RGB/Luma/YC/YC no Chroma), presets, Rec. 601/709/2100, 8-bit/float/HDR (cd/m²) scales, multi-scope grid, `scopes.read` | HDR-aware Lumetri maths, macOS EDR monitors, mastering metadata → tone-map peak, D-Log M (no published formula), HSL Secondary refine | 3–5 | -| M9 | More codecs | 🟡 | ProRes decode+encode, AAC decode+encode, HEVC Main/Main 10 decoder (bit-exact on 41 fixtures, ~225 fps 1080p), VP9 decoder (profiles 0–3, 8/10/12-bit, bit-exact on 50+ fixtures; WebM/MKV `V_VP9` and MP4 `vp09` import with key-frame-checked seeking), Matroska/WebM import (H.264/HEVC/VP9/ProRes/MJPEG + AAC/Opus/FLAC/MP3/Vorbis/PCM), Opus decoder (SILK/CELT/hybrid, 5.1/7.1 multistream; all RFC 8251 vectors range-exact; WebM/MKV/MP4), DNxHD/DNxHR decoder (all SMPTE ST 2019-1 CIDs, 8/10/12-bit, 4:2:2/4:4:4, interlaced, alpha; within IDCT precision of ffmpeg on 22 fixtures) + DNxHR LB/SQ/HQ/HQX/444 encoder and MOV `AVdh` export, AV1 decoder (Main profile, 8/10-bit, all intra/inter tools, loop filters, superres, film grain, intra BC, spatial layers; bit-exact vs libdav1d on 19 SVT fixtures + 22 libaom vectors; MP4 `av01` / WebM `V_AV1` import), MXF import (OP1a/OP-Atom; AVC with POC-ordered B pictures bit-exact, DNxHD/DNxHR, ProRes, PCM/AES3; MPEG-2 reported unsupported; index-table seeking, material package timecode), Ogg Opus/Vorbis (granule seeking, pre-skip, end trimming), image sequences, Broadcast WAV timecode | VP9 frame threading, AV1 threading/SIMD (~4 fps 1080p today), AV1 High/Professional profiles, MPEG-2 decoder, hardware decode | 12–20 | -| M10 | Graphics & captions | 🟡 | Caption tracks (Subtitle/CEA-608/708/Teletext formats, track style), SRT/WebVTT/SCC import+export (frame-exact, property-tested), caption editing (add/split/merge/trim/move, sync-locked insert/extract), Text panel Captions tab, burn-in in Program monitor and export; text engine (`crates/text`: bundled + system fonts, harfrust shaping, bidi, line breaking, paragraph layout, glyph cache; 3-line 1080p title ≈ 0.15 ms warm); graphic clips with text + shape layers (fill, 2 strokes, background, shadow, keyframable transform), Type tool with on-monitor editing, shape/pen tools, Properties/Essential Graphics editor, align/distribute; graphics templates (own `.fcgt` format, 8 original built-ins, Browse tab with engine-rendered thumbnails, export / install / apply / editable properties; never reads .mogrt), rolls/crawls with ease and pre/postroll, responsive pins and intro/outro, per-character styles, Upgrade Caption to Graphic, source graphics, Replace Fonts; MCC (608+708), EBU STL, TTML/IMSC1, DFXP (frame-exact, property-tested) | 608/708 embedding in video streams, speech-to-text | 12–18 | -| M11 | Interchange & project management | ✅ | `.fcproj` schema versions + migrations, atomic saves, Save a Copy/Revert, auto-save ring + crash-recovery journal (Preferences ▸ Auto Save, recovery prompt), FCP7 XML, FCPXML, EDL, OTIO; offline media (own slate) + Link Media (fingerprint-checked relink, folder remap, search, Align Timecode, Make Offline); proxies (ProRes Proxy/LT, H.264 ¼/½ background jobs, attach/detach/reconnect, monitor toggle, export full-res) + ingest (copy/transcode/proxies); Project Manager (collect, consolidate + transcode with handles, size estimate) | Rename media to clip names, image-sequence conversion, Media Browser-driven relink, smart (cross-drive) path tracking | 1–2 | -| M12–M16 | Multicam, web (WASM), platform, long tail | 🟡 | Multicam: audio sync (GCC-PHAT, sub-sample), Merge Clips, multi-camera source sequences, Multi-Camera view with live switching (1–9); L0–L4 crates compile to wasm32 | Web app shell (file access, WebCodecs, audio), scene detection, auto reframe, ~800 remaining commands and dialogs, performance hardening | 35–55 | +| Dimension | Ready | Hours | Doc | +|---|---|---|---| +| Features (editing, effects, colour, audio, graphics, export settings) | ~66% | 350–620 | [target-app-parity.md](docs/target-app-parity.md#by-feature-area) | +| UI / UX fidelity | ~60% | 120–200 | [ui-parity.md](docs/ui-parity.md) | +| File formats (containers, `.prproj`, interchange, captions, stills) | ~60% | 140–240 | [file-format-parity.md](docs/file-format-parity.md) | +| Codecs | ~60% | 150–250 | [codec-parity.md](docs/codec-parity.md) | +| Hardware acceleration | ~45% | 120–200 | [hardware-parity.md](docs/hardware-parity.md) | +| Localization | ~35% | 150–230 | [localization-parity.md](docs/localization-parity.md) | +| Performance | ~55% | 60–120 | [performance.md](docs/performance.md) | +| Stability | ~45% | 60–120 | [gaps.md G4](docs/gaps.md#g4-crashes-at-start-up-and-no-test-ci-on-pull-requests) | +| Platforms (macOS, Windows, Linux, FreeBSD, web) | ~60% | 50–90 | [gaps.md G9](docs/gaps.md#g9-windows-and-linux-at-run-time) | +| Plugins and ecosystem | ~5% | 200–400 | [gaps.md G11](docs/gaps.md#g11-no-plugin-hosting) | +| AI features | ~25% | 150–300 | [gaps.md G12](docs/gaps.md#g12-ai-features) | -## Running now +## Features -- **Hardware acceleration (#30):** landed: blend modes on the GPU (#32); VideoToolbox hardware decode + the `platform` FFI crate (#33); 33 common effects on the GPU (GPU2, GPU4, GPU5). Windows: Media Foundation / Direct3D 11 H.264 + HEVC decode (HW2) and VP9 + AV1 (HW3), NVENC H.264 export (HW4, opt-in) and H.265 export. Linux: VA-API H.264 + HEVC decode (HW5). Next: VA-API VP9 / AV1, zero-copy upload, Lumetri on the GPU. -- **Next:** [Where we are lacking](#where-we-are-lacking) items 1–5. GPU export waits for the export-crate work (#19 and the never-crash follow-up) to land. +| Area | Breadth | Ready | Hours | +|---|---|---|---| +| Editing, timeline, trimming, multicam | ~90% | ~70% | 80–140 | +| Effects and transitions | 100% (measured) | ~60% | 80–140 | +| Media import and codecs | ~75% | ~60% | 150–250 | +| Audio | ~88% | ~65% | 50–90 | +| Panels and UI fidelity | ~88% | ~60% | 120–200 | +| Colour | ~90% | ~70% | 40–70 | +| Graphics, titles, captions | ~85% | ~65% | 50–90 | +| Export and delivery | ~80% | ~65% | 60–110 | +| Project files and interchange | ~70% | ~45% | 90–160 | +| Preferences and project management | ~80% | ~65% | 20–40 | -## Log +Evidence for every row: [docs/target-app-parity.md](docs/target-app-parity.md#by-feature-area). +## Languages + +Premiere ships 10 interface languages. Measured from our catalogs (3,498 strings): + +| Language | Status | Strings | +|---|---|---| +| English | full | 100% | +| Simplified Chinese | partial (whole interface; engine errors English) | 99.6% | +| Spanish | partial (whole interface) | 99.9% | +| Hindi | none (no Devanagari shaping in the interface) | 0% | +| Arabic | none (no RTL interface) | 0% | +| French | none | 0% | +| Portuguese (Brazil) | menus only | 9.9% | +| Indonesian | none | 0% | +| Japanese | partial (whole interface) | 99.7% | +| German | none | 0% | +| Korean | none | 0% | +| Vietnamese | none | 0% | + +Also shipped: Ukrainian (menus only, 10%). Detail: [docs/localization-parity.md](docs/localization-parity.md). + +## Upcoming + +Ranked; detail and "done when" in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gaps.md). + +1. **No launch crashes, and CI on pull requests** (macOS 12, Intel UHD, Windows 11 reports; no workflow runs the tests today). 40–80 h. +2. **Open Premiere's `.prproj`**, the beta blocker. 60–120 h. +3. **10-bit / 4:2:2 camera media without hardware** (H.264 High 10 / 4:2:2, HEVC 4:2:2; #626). 30–50 h. +4. **Measure fidelity against Premiere** and add `cargo xtask parity`. 50–90 h. +5. **Real-world media corpus and workflow acceptance tests.** 40–70 h. +6. **Hardware acceleration, continued** (#30): zero-copy upload, more effects on the GPU, Quick Sync / AMD encoders, VideoToolbox Main 10. 120–200 h. +7. **UI fidelity:** Program monitor transform handles, Effect Controls, docking. 120–200 h. +8. **Plugin hosting** (VST3 / AU, then OpenFX). Owner decision needed. 200–400 h. + +## Progress log + +- **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)). +- **2026-10-10:** ~75 community PRs merged: GPU export rendering (Auto) with Vignette, Video Limiter and Lumetri basic / creative / vignette on the GPU (#421); Linux NVDEC H.264 / HEVC decode (#549); text-to-speech narrations with Kokoro-82M (#278); Japanese for the whole interface (#449) and Ukrainian menus (#407); ALSA default-device fallback (#254, fixes #23); drop import into the timeline (#517); system appearance (#505); timecode scrub editing (#576); NVENC RGBA input (#486); marker CSV export (#454); many fixes (#526–#575). +- **2026-10-09:** release 0.5.0. NVENC H.265 export on Windows, Main and Main 10 HDR (#328, #360); NVENC H.264 export (#386); VA-API H.264 / HEVC decode (#358); Source monitor playback (#423); multi-stream container audio, schema v13 (#361); New Sequence and Sequence Settings dialogs (#269, #355); Spanish for the panels (#238); background decoders and memory stress tests (#385, #381). - **2026-10-08 (Source range dragging):** implicit full-clip In/Out, video-only/audio-only/linked both controls, picture/waveform drag gestures and one-command undoable timeline placement. Shared Source/Program draggable frame-bounded handles and range translation, cancellation, Source marker/navigation routing and marked-range playback/looping. - **2026-10-08 (Source playback):** normal forward playback for video/audio media and subclips in the Source monitor, an independent Source clock, Play/Pause and focused Space, frame stepping, device-clock audio with wall-clock fallback, seeking, and end-of-clip stopping. Windows desktop review verified a real video file and standalone WAV, including In/Out marking. Reverse/shuttle Source playback and source sequences remain unsupported; no parity percentage changed. - **2026-10-08 (night):** HDR H.265 (HEVC Main 10, PQ and HLG, BT.2020, limited range) export on Windows through NVENC (#30): an HDR sequence now exports as real 10-bit HDR (`rgbf_to_yuv420_10` into P010 input buffers; VUI BT.2020 + PQ / HLG; the HDR10 mastering-display and content-light messages as SEI on every IDR for PQ, through the generic SEI payload array because NVENC 12.1 has no mastering-display field; `colr` / `mdcv` / `clli` in the sample entry), where the GPU reports 10-bit HEVC (new `register_hdr_probe` / `hdr_available` in the export crate; elsewhere, and on macOS, HDR sequences still tone-map to 8-bit SDR HEVC, and `settings.sdr` forces that). ffprobe reads `hevc` / `Main 10` / `hvc1` / `yuv420p10le` / `bt2020` / `smpte2084` or `arib-std-b67` / `bt2020nc` / `tv` with the mastering-display and content-light side data for PQ; ffmpeg's decode equals ours code for code; the in-process round trip is at 71.1 dB luma PSNR on the 10-bit scale with 803 distinct levels on a ramp and the peak code 940 intact (RTX 5060). 8-bit SDR H.265 is unchanged. @@ -209,6 +156,13 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining - **2026-09-30 (late morning):** keyframe value/velocity graphs; xtask gates; H.264 MP4 export; Lumetri curves, wheels, looks, HSL secondary. - **2026-09-30 (early morning):** H.264 decoder, ProRes, AAC; GPU compositor; MCP; Premiere 26 visual fidelity pass. -### Interface localization +## Revision history -Edit > Language switches English/Japanese/Spanish/Portuguese (Brazilian)/Ukrainian/Simplified Chinese and persists across restarts through `general.interfaceLanguage` (also available in Settings > General). Until a language is chosen, System Language (the default) follows the operating system's or browser's preferred languages and falls back to English. Commands `app.language.english`, `app.language.japanese`, `app.language.spanish`, `app.language.portuguese`, `app.language.ukrainian` and `app.language.chinese` are reachable through the control channel. Translations live in one catalog per language (`crates/ui-egui/src/i18n/.tsv`); UI code wraps its strings in `tl!("…")` / `tlf!("…{name}", name)`, and names that come from registries (effects and their parameters, settings, commands, panels, workspaces, presets) are translated where they are drawn. **Spanish, Japanese and Simplified Chinese cover the whole interface**: menus, panels, dialogs, the Settings pages, the Effects/Effect Controls registry, the Keyboard Shortcuts dialog and status messages; tests fail when a `tl!` literal, a menu label or a registry name has no Spanish entry (`ja.tsv` has the same entries, but the tests do not require them yet; `zh-cn.tsv` must cover every `tl!` literal and menu label too). Searches match the displayed labels as well as English sources. Brazilian Portuguese and Ukrainian cover the core menus; untranslated labels use English. Ukrainian uses the bundled Cyrillic interface fonts. Engine error messages, the control channel, the CLI and MCP stay in English. Japanese text uses the Japanese fonts from [craft-fonts](https://github.com/storytold/craft-fonts) when FilmCraft is built with them (`CRAFT_FONTS_DIR`; all official releases, including the web build), otherwise a Japanese font already installed on the system; with neither, switching to Japanese is refused with a message. Simplified Chinese (`zh-cn`; System Language picks it for `zh`, `zh-CN`, `zh-SG` and `zh-Hans`, not for Traditional locales) uses the Chinese fallback font the interface always installs (craft-fonts, otherwise an installed system face); with none, switching to it is refused the same way. Existing vertical text support is preserved. +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% per audience | +| 2026-10-10 | minor | Headline adds mainstream practitioner (~55%) and essentials user (~60%); ready for real work ~55% -> ~56% (formula written out, same inputs); beta distance ~19 points | +| 2026-10-10 | minor | Stage banner links the core-workflow gate (all six workflows pass on macOS; stays alpha) | +| 2026-10-10 | major | Restructured to the craftrules progress-docs shape: stage banner, headline numbers, dimension / feature / language tables, upcoming; re-measured against Premiere 26.5.2 (breadth ~87 → ~86%, ready ~50–60% → ~55%); honest assessment and scorecard moved to docs/target-app-parity.md, "Where we are lacking" to docs/gaps.md, milestones to docs/roadmap.md, interface localization to docs/localization-parity.md | +| 2026-10-08 | minor | Log entries for HEVC export, Source playback and range dragging | +| 2026-10-05 | major | Honest assessment added: checklist ~87% vs ready for real work ~50–60% | diff --git a/docs/agents.md b/docs/agents.md index 2e303cbb..094877d4 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -244,8 +244,8 @@ media, sidecar and `ATTRIBUTION.md` entry. 1. [AGENTS.md](../AGENTS.md): absolute rules (assets, clean-room, licences). 2. [CLAUDE.md](../CLAUDE.md): working instructions and non-negotiables. -3. [ROADMAP.md](../ROADMAP.md): read the **honest assessment** and **Where we are lacking** first, then - milestones, what's done and what's running. +3. [ROADMAP.md](../ROADMAP.md) for the summary, then [gaps.md](gaps.md) (the ranked shortfalls) first, + [target-app-parity.md](target-app-parity.md) and [roadmap.md](roadmap.md) (milestones, current focus). 4. [architecture.md](architecture.md), then the README and tests of the crate you'll touch. 5. [contributing.md](contributing.md) (how to add things, gates) and [testing.md](testing.md). @@ -256,15 +256,16 @@ everything you need to contribute is in the public docs above. Ask a maintainer ## 5. Autonomous work loop 1. **Orient.** Pick the next task: the next unchecked task in the maintainer status file, or an open - ROADMAP item. Prefer the gaps in ROADMAP's *Where we are lacking* (speed, correctness on real - media, measurement, Windows/Linux) over adding more checklist items. Read the relevant + [gaps.md](gaps.md) entry. Prefer those gaps (stability, `.prproj`, camera media, measurement, + hardware, Windows/Linux) over adding more checklist items. Read the relevant architecture section and crate README. 2. **Plan tests first.** Write down the acceptance test before writing code. 3. **Implement and test.** 4. **Verify.** Run all gates (`cargo xtask ci`). For UI work, run the app with `--control`, drive it and look at the screenshots (§3). 5. **Record.** Update the crate README (behaviour decisions, test results, limitations) and - ROADMAP.md when a milestone moves. Commit as `M.: …`, then move on to the next task. + the progress docs when a milestone moves (ROADMAP.md, docs/roadmap.md, the relevant parity doc + and gaps.md, with their timestamps). Commit as `M.: …`, then move on to the next task. For parallel agents, use one git worktree and one `CARGO_TARGET_DIR` per agent, and keep each crate with one owner ([contributing.md §5](contributing.md#5-parallel-work-several-agents-or-worktrees)). @@ -295,17 +296,17 @@ A feature is done when: - [ ] every new interactive widget has an automation id; - [ ] you have driven it through the control channel and reviewed a screenshot; - [ ] all gates pass (`cargo xtask ci`), and new assets have sidecars; -- [ ] the crate README and ROADMAP.md are updated where relevant; +- [ ] the crate README and the progress docs (ROADMAP.md, docs/gaps.md, the parity docs) are updated where relevant; - [ ] it is committed with its task id. ### Reporting progress honestly -ROADMAP.md keeps two numbers: the **feature checklist** (does it exist?) and **ready for real -work** (does it hold up?). When you update either: +ROADMAP.md and [target-app-parity.md](target-app-parity.md) keep two numbers: **feature breadth** +(does it exist?) and **ready for real work** (does it hold up?). When you update either: - say how a number was obtained: *measured* (a test, a diff, a benchmark you ran) or *estimated*; - count an approximation, a stub or a partly wired setting as such, never as done; -- record known bugs and limitations in the crate README and the honest assessment, not only +- record known bugs and limitations in the crate README and [gaps.md](gaps.md), not only what works; - benchmarks state the machine, the load average and before/after on the same build; - don't raise a percentage without evidence a reviewer can rerun. diff --git a/docs/architecture.md b/docs/architecture.md index 9481641f..a64d130f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,5 +1,7 @@ # Architecture +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** trivial (status line, revision history and roadmap link added; content checked against the crate list) · **Target:** Adobe Premiere Pro 2026 (26.5.2) + FilmCraft is a Cargo workspace of small crates with strictly enforced layering. The engine is headless: every feature can be reached without a window, and the egui UI is one client among the CLI, the JSON control channel and the MCP server. @@ -752,4 +754,10 @@ The layer table reserves names for crates that don't exist yet: `riff`, `mjpeg`, other OS integration stays in `apps/filmcraft`.) Until they exist, that work lives elsewhere: keyframes and effect definitions in `project`, effects and the audio mix in `render`, playback in `ui-egui`, and OS integration (cpal, rfd, -native menus) in `apps/filmcraft`. [ROADMAP.md](../ROADMAP.md) has the milestone status. +native menus) in `apps/filmcraft`. [roadmap.md](roadmap.md) has the milestone status and [gaps.md](gaps.md) what is missing. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | trivial | Status line and revision history added (craftrules progress-docs standard); milestone link points at docs/roadmap.md | diff --git a/docs/codec-parity.md b/docs/codec-parity.md new file mode 100644 index 00000000..83416bf2 --- /dev/null +++ b/docs/codec-parity.md @@ -0,0 +1,63 @@ +# 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 + +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 +paths per platform in [hardware-parity.md](hardware-parity.md). All our codecs are clean-room +pure Rust from public specifications ([AGENTS.md](../AGENTS.md) §2); ffmpeg is a test oracle only. + +Premiere's list comes from Adobe's published supported-formats pages and the names of the +installed bundle's `MediaIO/systempresets` and `Frameworks` entries (listings only, never their +contents). Percent is ready-for-real-work for that codec: depth, profiles, speed. + +## Video + +| Codec | Premiere | Our decode | Our encode | Fidelity and tests | % | Est. | +|---|---|---|---|---|---|---| +| 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 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) | +| VP9 | not a Premiere import format on all versions | own decoder profiles 0–3, 8/10/12-bit, bit-exact on 50+ fixtures, frame threading | none | conformance | ahead | — | +| APV (RFC 9924) | — (Samsung's pro codec; not in Premiere) | own decode | own encode | spec tests | ahead | — | +| MPEG-2 / MPEG-1 | decode incl. 4:2:2 (IMX, XDCAM); encode DVD / Blu-ray / MXF | own decoder (4:2:2, interlaced, field pictures, IEEE 1180 IDCT) | **none** | conformance fixtures | 60% | 20–35 h (encoder) | +| Motion JPEG | decode + encode | yes | yes (MOV) | — | 90% | — | +| JPEG 2000 (MXF, DCP) | decode + encode (Kakadu) | none | none | — | 0% | 40–70 h | +| JPEG XS | decode (`libjpegxs`) | none | none | — | 0% | 20–40 h | +| DV / DVCPRO / DV100 | decode + encode | none | none | — | 0% | 15–25 h | +| MPEG-4 Part 2 | decode | none | none | — | 0% | 10–20 h | +| WMV / VC-1 | decode, encode on Windows | none | none | — | 0% | 20–35 h | +| Camera RAW: R3D (incl. R3D NE), ARRIRAW, Sony RAW / X-OCN, Canon RAW, BRAW (plug-in), Cinema DNG | decode (vendor SDKs) | none (#383) | — | — | 0% | 60–100 h for the ones with public specs; vendor-SDK-only formats need an owner decision | +| GIF (animated) | encode | decode as still | encode | — | 80% | — | +| Uncompressed / v210 / RGB | decode + encode (AVI, MOV) | partial (MOV PCM-style video not verified) | none | — | 30% | 8–15 h | + +## Audio + +| Codec | Premiere | Our decode | Our encode | Fidelity and tests | % | Est. | +|---|---|---|---|---|---|---| +| PCM (WAV, BWF, AIFF, MOV, MXF, Blu-ray / DVD LPCM) | yes | yes, BWF timecode | WAV, AIFF, MOV, MXF | sample-exact | 95% | — | +| AAC (LC, HE, LATM) | decode + encode | own decoder (LC, HE core), LATM | own LC encoder | loudness oracle vs ffmpeg | 90% | 3–5 h | +| AC-3 | decode (licensed) | own decoder (ATSC A/52) | none | conformance | 80% | — | +| **E-AC-3 (Dolby Digital Plus)** | decode | **refused** (bsid 11–16, #647) | none | — | 0% | 15–25 h | +| MP3 / MP2 | decode + MP3 encode | decode (symphonia, MPL-2.0; own MP2) | **none** | — | 60% | 10–20 h (MP3 encoder) | +| Opus | — (not a Premiere import format) | own decoder, RFC 8251 range-exact | none | conformance vectors | ahead | — | +| FLAC, ALAC, Vorbis | partial | decode (symphonia); FLAC in MP4 silent (#603) | none | — | 70% | 3–6 h | +| WMA | decode on Windows | none | none | — | 0% | 10–20 h | + +## Summary + +- **Ahead of Premiere:** VP9, Opus, APV decode / encode; bit-exact software decoders everywhere, + including Linux and FreeBSD, where Premiere does not run. +- **Behind, by user impact:** 10-bit / 4:2:2 H.264 and HEVC in software ([G3](gaps.md#g3-10-bit-and-422-camera-media-needs-hardware)), + E-AC-3, camera RAW, HEVC and AV1 software encoding, MPEG-2 / JPEG 2000 encoding for broadcast + and DCP, DV, WMV. +- **Codec dimension: ~60%, 150–250 h** ([target-app-parity.md](target-app-parity.md#by-dimension)). + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 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 | diff --git a/docs/contributing.md b/docs/contributing.md index c38791fc..245334b0 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -136,7 +136,7 @@ comes with a regression test; parsers, decoders and commands get fuzz / hostile- [ROADMAP.md](../ROADMAP.md). If your change has no task id, use the area: `docs: …`, `README: …`, `ROADMAP: …`. - Commit only green states (all gates pass). -- When a milestone lands, update its row in `ROADMAP.md` (status, what's done, estimates). +- When a milestone lands, update its row in [roadmap.md](roadmap.md) (status, what's done, estimates), the summary in `ROADMAP.md`, and the parity doc and [gaps.md](gaps.md) entry it moves, with their "Last updated" lines and revision history. - AI agents end commit messages with the attribution line their environment requires. ## 5. Parallel work (several agents or worktrees) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md new file mode 100644 index 00000000..b19262c8 --- /dev/null +++ b/docs/file-format-parity.md @@ -0,0 +1,94 @@ +# File format parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (new file: every file type Premiere Pro 26.5.2 reads or writes, against ours) · **Target:** Adobe Premiere Pro 2026 (26.5.2) and Media Encoder 2026 + +Containers, stills, audio files, project and interchange files, captions and presets. Codecs inside +the containers are in [codec-parity.md](codec-parity.md). Our import list is +`filmcraft_media::{VIDEO,AUDIO,STILL}_EXTENSIONS` plus the openers in `filmcraft_codecs::openers()`; +our export list is `filmcraft_export::Format`. Premiere's list is from Adobe's supported-formats +documentation and the exporter folders in the installed bundle's `MediaIO/systempresets` (names +only). "Tested" names the evidence. + +## Project and interchange + +| Format | Premiere | Read | Write | Tested | % | +|---|---|---|---|---|---| +| **Premiere project `.prproj`** | native | **no** | **no** | — | **0%** ([G2](gaps.md#g2-premieres-prproj-cannot-be-opened)) | +| Our project `.fcproj` | — | yes, schema migrations | yes, atomic, auto-save, recovery journal | migration tests | — | +| FCP7 XML (`xmeml`) | read + write | yes | yes | round-trip tests; Premiere-exported XML slow (#461) | 75% | +| FCPXML | read (via XtoCC) | yes | yes | round-trip tests | 80% | +| AAF (Edit Protocol) | read + write | yes | yes (embedded / linked / consolidated) | own CFB; **never opened in Media Composer / Pro Tools** | 55% | +| OMF 2.0 | write | no | yes | never opened in Pro Tools | 50% | +| EDL (CMX 3600) | read + write | yes | yes | tests | 85% | +| OpenTimelineIO | read + write (26.0) | yes | yes | tests | 80% | +| Avid Log Exchange (ALE) | write | no | yes | — | 70% | +| Marker CSV / text | write | — | yes (#454) | — | 80% | +| Selection as project | Premiere project | — | `.fcproj` only | — | 50% | +| Templates (`.prproj` templates) | yes | — | our templates | — | 70% | +| Motion Graphics templates `.mogrt` | read + write | **refused by design** (clean-room, AGENTS.md §1); our `.fcgt` instead | `.fcgt` | — | n/a | +| Effect presets `.prfpset` | read + write | no | our JSON presets | — | 40% | +| Keyboard shortcuts `.kys` | read + write | no | our presets incl. a Premiere layout | — | 60% | +| Export presets `.epr` | read + write | no | our presets (import / export) | — | 50% | +| LUTs `.cube`, `.3dl` | read | yes (1D / 3D / shaper) | — | tetrahedral CPU = WGSL | 95% | + +## Video containers + +| Container | Premiere | Read | Write | Tested | % | +|---|---|---|---|---|---| +| MP4 / M4V / 3GP | read + write | yes | yes (H.264, HEVC + AAC) | ffprobe, round trips | 90% | +| QuickTime MOV | read + write | yes | yes (H.264, HEVC, ProRes, DNxHR, APV, MJPEG, PCM) | ffprobe | 90% | +| MXF OP1a / OP-Atom | read + write (XDCAM, AVC-Intra, P2, AS-10 / AS-11, JPEG 2000, DNx) | yes (AVC, DNx, ProRes, MPEG-2, PCM / AES3, timecode) | OP1a (DNxHR, ProRes, H.264), OP-Atom | index-table seeking tests | 70% | +| MPEG TS / PS (AVCHD `.mts`, `.m2ts`, `.ts`, `.mpg`, `.vob`, `.mod`) | read; write MPEG-2 / Blu-ray / DVD | yes | **no** | fixtures | 65% | +| Matroska / WebM | read (limited) | yes (H.264, HEVC, VP9, AV1, ProRes, MJPEG + AAC, Opus, FLAC, MP3, Vorbis, PCM); WebM black report (#432), MKV multi-audio (#601) | no | fixtures | 75% | +| **AVI** | read + write | **no** (extension listed, no demuxer, #598) | no | — | 0% | +| WMV / ASF | read; write on Windows | no | no | — | 0% | +| FLV / F4V | read + write | no | no | — | 0% | +| DCP | write (Wraptor) | no | no | — | 0% | +| APV raw / Y4M | — | yes | APV in MOV | — | ahead | +| Ogg (Opus / Vorbis) | — | yes | no | granule-seek tests | ahead | +| Camera folder structures (XDCAM, P2, AVCHD, Canon XF, RED) | Media Browser reads them | AVCHD files yes; spanned clips and metadata no | — | — | 30% | + +## Stills and image sequences + +| Format | Premiere | Read | Write (frame / sequence) | % | +|---|---|---|---|---| +| PNG, JPEG, TIFF, BMP, GIF | read + write | yes | PNG / TIFF / BMP sequences, GIF; **no JPEG sequence** | 80% | +| WebP | — | yes | no | ahead | +| **PSD** (layers as sequence) | read (layered) | no | — | 0% | +| **OpenEXR** | read + write | no | no | 0% | +| **DPX** | read + write | no | no | 0% | +| Targa | read + write | no | no | 0% | +| HEIF / HEIC | read | no | — | 0% | +| Radiance HDR, AI / EPS, ICO, PTL | read | no | — | 0% | + +## Audio files + +| Format | Premiere | Read | Write | % | +|---|---|---|---|---| +| WAV / BWF (timecode, iXML partial) | read + write | yes | yes | 90% | +| AIFF / AIFC | read + write | yes | yes | 95% | +| MP3 | read + write | read | **no** | 60% | +| AAC / M4A | read + write | yes | inside MP4 / MOV only, **no AAC-only file** | 70% | +| FLAC, Ogg, Opus | partial | yes | no | ahead on read | +| WMA | read (Windows) | no | no | 0% | + +## Captions and subtitles + +| Format | Premiere | Read | Write | Tested | % | +|---|---|---|---|---|---| +| SRT, WebVTT, SCC | read + write | yes | yes | frame-exact, property-tested | 95% | +| MCC (608 + 708), EBU STL, TTML / IMSC1, DFXP | read + write | yes | yes | frame-exact | 90% | +| 608 / 708 embedded in the video stream | write (MXF, H.264 SEI) | no | **no** | — | 0% | +| Burn-in | yes | — | yes | golden tests | 95% | + +## Summary + +**File formats dimension: ~60%, 140–240 h** (codecs counted separately). The beta blocker is +`.prproj`. After it, by user impact: AVI and WMV import, EXR / DPX / PSD stills, AAF / OMF +validation in Avid and Pro Tools, DVD / Blu-ray / DCP / broadcast MXF delivery. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | Created: format-by-format comparison with Premiere 26.5.2 from the import / export registries, crate READMEs, the installed bundle's exporter listings and open issues | diff --git a/docs/gaps.md b/docs/gaps.md new file mode 100644 index 00000000..3532ae7b --- /dev/null +++ b/docs/gaps.md @@ -0,0 +1,240 @@ +# Where FilmCraft falls short of Premiere Pro + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (new file: ROADMAP's "Where we are lacking" re-measured against Premiere Pro 26.5.2 and the open issue list) · **Target:** Adobe Premiere Pro 2026 (26.5.2) + +Every known shortfall, one entry each, ranked by what most stops an editor using FilmCraft for real +work. This is the work list: agents should prefer it over adding checklist items. Numbers are in +[target-app-parity.md](target-app-parity.md); estimates are Opus 5.5 agent-hours, calibrated there. +When you close or shrink a gap, update its entry, its parity doc and the revision history. + +Summary by kind: **feature gaps** G1, G2, G12, G13, G18, G19; **UI / UX** G8; **file formats** +G2, G10, G13, G15; **codecs** G3, G10; **hardware** G5; **localization** G14; **stability and +platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** G1, G6, G7; +**web** G20. + +| # | Gap | Kind | Blocks beta | Estimate | +|---|---|---|---|---| +| G1 | Nothing compares our output with Premiere's | measurement | no, but every ready number depends on it | 40–70 h | +| G2 | Premiere's `.prproj` cannot be opened | file format | **yes** | 60–120 h | +| G3 | 10-bit and 4:2:2 camera media needs hardware | codec | yes | 30–50 h | +| G4 | Crashes at start-up, and no test CI on pull requests | stability | yes | 40–80 h | +| G5 | Hardware acceleration is partial | hardware | partly | 120–200 h | +| G6 | No real-world media corpus or workflow acceptance tests | measurement | yes | 40–70 h | +| G7 | No measured parity tool in CI | measurement | no | 10–20 h | +| G8 | UI fidelity: monitor handles, Effect Controls, docking | UI / UX | partly | 120–200 h | +| G9 | Windows and Linux at run time | platforms | partly | 50–90 h | +| G10 | Container and codec gaps on import | file format, codec | partly | 120–200 h | +| G11 | No plugin hosting | ecosystem | no | 200–400 h | +| G12 | AI features | AI | no | 150–300 h | +| G13 | Delivery formats | file format | no | 60–110 h | +| G14 | Localization: 8 of the 12 key languages missing | localization | no | 150–230 h | +| G15 | Interchange never validated in Avid / Pro Tools; slow XML import | file format | partly | 20–40 h | +| G16 | Performance reports on 0.5.0 | performance | partly | 30–60 h | +| G17 | Audio device and playback reliability | stability | partly | 20–40 h | +| G18 | Effects depth: approximations and missing behaviour | feature | no | 60–110 h | +| G19 | Speech to text off in release builds | feature, AI | no | 5–10 h | +| G20 | The web app shell | platforms | no | 40–80 h | + +## G1. Nothing compares our output with Premiere's + +- **Missing:** a fidelity harness. Every effect, transition, Lumetri control, blend mode, keyframe + interpolation and audio effect counts once it exists; nothing renders the same project in + Premiere and FilmCraft and compares frames or samples. Effects described as approximations + count the same as exact ones. +- **Evidence:** catalogue tests check names and folders only. EffectCraft's live After Effects + comparison (its gaps.md G1) found real bugs in features marked done on its first run. +- **Impact:** "ready for real work" stays an estimate; colour and effect differences surface only + when a user notices. +- **Done when:** a corpus of original test projects (our own media), opened in Premiere through + FCP7 XML / OTIO, rendered by both, with per-effect PSNR / ΔE and audio-sample scores reproducible + from one command; Premiere output stays local in `plan/premiere/` (gitignored). Owner runs + Premiere (installed). **40–70 h.** Doc: [target-app-parity.md](target-app-parity.md). + +## G2. Premiere's `.prproj` cannot be opened + +- **Missing:** import of Premiere project files. `grep -ri prproj crates apps` finds nothing. + Today an editor must export FCP7 XML, AAF or OTIO from Premiere first, losing bins, effects and + most metadata; there is no "Selection as Premiere Project" export either. +- **Impact:** the beta bar requires opening the target's main format; every switching editor's + existing work is behind this. +- **Approach:** `.prproj` is gzip-compressed XML. Clean-room from projects we author ourselves in + Premiere (the owner has `plan/premiere/fixtures/filmcraft_ref.prproj`): sequences, tracks, clips, + in/out, speed, transitions, markers, bins, labels, then the common effect parameters (Motion, + Opacity, Lumetri basic, Volume). Report what was not imported. Export later. **60–120 h.** + Doc: [file-format-parity.md](file-format-parity.md). + +## G3. 10-bit and 4:2:2 camera media needs hardware + +- **Missing:** our H.264 decoder is 8-bit 4:2:0 only (`crates/h264/README.md`: no High 10, High + 4:2:2, 4:4:4, lossless, interlaced field / MBAFF); HEVC has no 4:2:2 / RExt. Most mirrorless and + cinema cameras record exactly these (Sony XAVC S-I / XAVC HS 4:2:2 10-bit, Panasonic, Canon + XF-AVC, Fujifilm). +- **Evidence:** #626 (Panasonic Lumix 4K 10-bit 4:2:2 MOV). On macOS VideoToolbox decodes 10-bit + 4:2:2 H.264 / HEVC on chips that support it; Windows Media Foundation and Linux decline them, and + there is no software fallback, so the clip fails. Premiere decodes them everywhere (and in + hardware on Apple silicon, Intel and NVIDIA Blackwell). +- **Impact:** camera originals from most current cameras do not open on Windows or Linux. +- **Estimate:** H.264 High 10 + High 4:2:2 (chroma geometry, deblocking, transforms) 15–25 h; + HEVC Main 4:2:2 10 (RExt subset) 15–25 h. Doc: [codec-parity.md](codec-parity.md). + +## G4. Crashes at start-up, and no test CI on pull requests + +- **Missing:** a launch that never fails, and CI that runs the gates. +- **Evidence:** open issues: macOS 12 Monterey aborts on a missing VideoToolbox symbol (#468, + #661, #655), Intel UHD graphics (`igvk64.dll`, #512), Windows 11 does not run or fails after setup + (#380, #582, #413, #477), crash on new project + import (#439), "it just keeps crashing" (#687). + `.github/workflows/` holds release, packaging lint, FreeBSD and Windows ARM64 builds; **none runs + `cargo test` or `cargo xtask ci` on a pull request**, so 243 PRs merged since 10-05 were gated by + contributors' local runs (main needed "green again after the batch merges" on 10-10). +- **Impact:** a user who cannot start the app gets zero value; regressions land unnoticed. +- **Done when:** weak-linked or runtime-loaded OS symbols on old macOS, a software / safe-mode + start when the GPU adapter fails, a PR workflow running `cargo xtask ci` on macOS, Windows and + Linux, and a crash-report triage pass. **40–80 h.** + +## G5. Hardware acceleration is partial + +- **Have:** hardware decode on macOS (VideoToolbox H.264 / HEVC), Windows (Media Foundation / + D3D11: H.264, HEVC, VP9, AV1) and Linux (VA-API and NVDEC: H.264, HEVC); hardware encode + VideoToolbox H.264 / HEVC 8-bit, NVENC H.264 (Windows, Linux) and HEVC incl. Main 10 HDR + (Windows); GPU compositing, blend modes, 34 effect ids incl. Lumetri basic / creative / vignette, + GPU export rendering (#421). +- **Missing:** zero-copy decoded frames into wgpu (every frame is read back to the CPU and + re-uploaded); VideoToolbox Main 10 / HDR HEVC encode and B-frames; ProRes hardware decode / + encode on Apple silicon; Intel Quick Sync and AMD AMF encoders (and their 4:2:2 decode); + VA-API VP9 / AV1 and VA-API encode; HEVC encode on Linux; AV1 hardware encode; ~48 of Premiere's + ~82 accelerated effects (keys, masks, Lumetri curves / wheels / HSL, distortions, transitions); + external video I/O (DeckLink / AJA, Mercury Transmit), control surfaces (EUCON / Mackie); + a CPU fallback adapter renders everything on the CPU (#632). +- **Estimate:** 120–200 h (hardware we don't own needs a human). Doc: + [hardware-parity.md](hardware-parity.md). + +## G6. No real-world media corpus or workflow acceptance tests + +- **Missing:** a pinned corpus of phone, camera and screen recordings (variable frame rate, + damaged files, long GOP, odd containers, multi-track audio), each with an import → edit → export + round trip checked against an ffprobe / ffmpeg oracle; scripted MCP sessions that do real jobs + end to end. +- **Evidence:** export stopped producing frames with iPhone HEVC clips on Linux (#480); WebM shows + black (#432); only the first audio track imported (#397, #416, #601; multi-stream support landed + in #361, reporters not yet confirmed); bitrate target produced 6.1 Mb/s for 40 (#371). +- **Done when:** corpus fetched pinned and sha256-verified (as PhotoCraft's `cargo xtask corpus`, + craftrules `standards/test-corpora.md`), runs nightly, ≥ 50 files. **40–70 h.** + +## G7. No measured parity tool in CI + +- **Missing:** `cargo xtask parity` (menus, commands, effects, transitions, panels, preferences, + formats against `plan/premiere/` snapshots), presence and fidelity reported separately (exact, + approximate, stub), generating `docs/parity-checklist.md`. +- **Evidence:** the 92% menu figure in this pass came from an ad hoc label-matching script. +- **Estimate:** 10–20 h (the menu snapshot is local-only, so the tool needs a committed, Adobe-free + list of menu paths or runs only where `plan/` exists). + +## G8. UI fidelity: monitor handles, Effect Controls, docking + +- **Missing:** on-screen transform handles for Motion / Transform in the Program monitor (#639); + Effect Controls keyframe zoom bar, draggable divider, row lines (#640–#645); panel docking + rearrangement (#493); clicking empty space to deselect (#683); filmstrip thumbnails (#614); + marquee selection in the Project panel (#578); Freeform bins and middle-mouse pan (#579); + Program monitor Button Editor (+) (#431); keyframe curves for Position (#448); transition + editing controls (#577); track renaming (#654); window controls on macOS (#637); UI scale on + X11 (#457). +- **Estimate:** 120–200 h. Doc: [ui-parity.md](ui-parity.md). + +## G9. Windows and Linux at run time + +- **Missing:** routine runtime testing off macOS. Linux playback stutter (#617), Wayland title + bar (#433), case-sensitive extensions (#593), "Report an issue" link (#642), Windows bins cannot + be left (#587), Sequence Settings won't open on Windows (#586, #351), old hardware (#491). +- **Estimate:** 50–90 h (overlaps G4's CI work). + +## G10. Container and codec gaps on import + +- **Missing:** E-AC-3 (#647; our AC-3 decoder refuses bsid 11–16), AVI (#598; listed as an + extension but no demuxer), WMV / ASF, DV / DVCPRO / DV100, MPEG-4 Part 2, FLAC in MP4 (#603), + camera RAW (R3D incl. R3D NE, ARRIRAW, Sony RAW / X-OCN, Canon RAW, BRAW, ProRes RAW; #345, + #383), JPEG 2000 / JPEG XS MXF, Cinema DNG; stills: PSD, EXR, DPX, Targa, HEIF / HEIC, Radiance + HDR, AI / EPS; transparent video (#402); Sony start timecode in `rtmd` (#460); stereo 3D flag + (#500); MKV multi-audio (#601). +- **Estimate:** 120–200 h (camera RAW alone 60–100 h, and some SDKs have licences we cannot use: + clean-room from public specs only where specs exist). Docs: [codec-parity.md](codec-parity.md), + [file-format-parity.md](file-format-parity.md). + +## G11. No plugin hosting + +- **Missing:** VST3 / Audio Units, OpenFX, Premiere / After Effects plug-in APIs, UXP panels + (#325). +- **Impact:** many professional editors depend on third-party effects and audio plug-ins. +- **Needs:** an owner decision (FFI under the `unsafe` rule, AGENTS.md §0.3). **200–400 h.** + +## G12. AI features + +- **Missing:** Object Mask (Premiere 26.0, #406, #694), Generative Extend (needs a large video + model; not reachable locally), media-intelligence search, caption translation, model-based + Enhance Speech, Auto Reframe beyond heuristics, auto colour, Auto-Tag Audio Types. +- **Have:** local Whisper transcription (off in releases, G19), Kokoro-82M text-to-speech + narration (#278), scene edit detection, colour match. +- **Needs:** openly licensed models we can ship. **150–300 h.** + +## G13. Delivery formats + +- **Missing:** AV1 export (software or hardware), MPEG-2 / DVD / Blu-ray, DCP, JPEG 2000 MXF, + AS-10 / AS-11, XDCAM HD MXF, P2, EXR / DPX / Targa / JPEG sequences, MP3 and AAC-only audio, + ProRes 4444 / XQ in the export UI (#342; the encoder has the profiles), interlaced encoding, + smart render, HEVC software encoder (HEVC exists only where a hardware encoder does). +- **Estimate:** 60–110 h. Doc: [file-format-parity.md](file-format-parity.md). + +## G14. Localization: 8 of the 12 key languages missing + +- **Have:** Spanish, Japanese and Simplified Chinese catalogs cover ~99.6–99.9% of the ~3,500 + cataloged strings; Portuguese (Brazil) and Ukrainian ~10% (menus). +- **Missing:** Hindi, Arabic (no RTL interface layout; Arabic titles reported broken, #395), + French (#498), Indonesian, German (#490), Korean (#508; needs Hangul fonts in craft-fonts), + Vietnamese, Portuguese beyond menus; Premiere also ships Italian and Russian. Engine errors, + CLI and MCP stay English. No language has a recorded native-speaker review. +- **Estimate:** 150–230 h. Doc: [localization-parity.md](localization-parity.md). + +## G15. Interchange never validated in Avid / Pro Tools; slow XML import + +- **Missing:** AAF and OMF exports have never been opened in Media Composer or Pro Tools; FCP7 XML + / OTIO import of a Premiere sequence took 97 s / 66 s for three media files (#461); `file.import` + merges into existing project content in headless round trips (#592). +- **Estimate:** 20–40 h (needs a human with Avid / Pro Tools, both installed on the owner's Mac). + +## G16. Performance reports on 0.5.0 + +- **Missing:** steady UI under load. Constant lag after 0.4 → 0.5 (#525), ~100% CPU (#523), + "Render Effects In to Out" (#424). AV1 software decode is ~4 fps at 1080p single-threaded; 8K + is not real time. +- **Estimate:** 30–60 h. Doc: [performance.md](performance.md). + +## G17. Audio device and playback reliability + +- **Evidence:** Asus Xonar DX output broken (#605), no audio (#602), audio cuts out with + overlapping clips (#336), buggy audio (#410), Linux stutter (#617). No ASIO; cpal only. +- **Estimate:** 20–40 h. + +## G18. Effects depth: approximations and missing behaviour + +- **Missing:** Warp Stabilizer is 2-D (Premiere's is 3-D subspace), Morph Cut approximate, optical + flow renders as frame blending, Transform shutter angle adds no motion blur (#415), Ultra Key + Setting scales only two factors (#458), Lumetri HDR-aware maths, multicam paging beyond 16 + angles, redesigned 26.0 mask tools (rounded corners, constrained lines). +- **Estimate:** 60–110 h. + +## G19. Speech to text off in release builds + +- **Missing:** the `whisper` feature is off by default and in releases, so Transcribe Sequence, + text-based editing and auto captions need a source build. +- **Estimate:** 5–10 h (model download UX, licence check). + +## G20. The web app shell + +- **Missing:** file access, WebCodecs decode, audio in the browser build beyond what exists; + image-sequence export downloads drop frames in Chrome (#378). +- **Estimate:** 40–80 h. Doc: [web.md](web.md). + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | Created from ROADMAP.md's "Where we are lacking" (2026-10-05); re-ranked against Premiere 26.5.2 and the 188 open issues; added `.prproj`, 10-bit / 4:2:2 camera decode, launch crashes and missing PR CI, localization, delivery formats | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md new file mode 100644 index 00000000..d39f4b9f --- /dev/null +++ b/docs/hardware-parity.md @@ -0,0 +1,87 @@ +# Hardware parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (new file: hardware decode / encode per platform and codec, GPU effects, devices; re-measured after NVDEC #549 and GPU export #421) · **Target:** Adobe Premiere Pro 2026 (26.5.2) + +Each hardware feature Premiere uses, per platform, against ours. Implementation detail lives in +[`crates/platform/README.md`](../crates/platform/README.md) (decode / encode backends, the one +crate allowed `unsafe`, [ADR 0001](adr/0001-platform-ffi.md)), the GPU compositor in +[architecture.md](architecture.md), and timings in [performance.md](performance.md). +Hardware paths are always optional: our software codecs are the tested fallback, and a hardware +decoder that fails mid-stream hands over to software (`HybridDecoder`). + +Premiere's side is from Adobe's published GPU and hardware-acceleration documentation and +release notes (Premiere does not run on Linux). + +## Hardware decode + +| Codec / profile | Premiere macOS | **Ours macOS** (VideoToolbox) | Premiere Windows | **Ours Windows** (Media Foundation / D3D11) | **Ours Linux** (NVDEC, VA-API) | +|---|---|---|---|---|---| +| H.264 8-bit 4:2:0 | yes | **yes** | Intel, NVIDIA, AMD | **yes** (Baseline / Main / High) | **yes** (NVDEC; VA-API Intel / AMD / Mesa) | +| H.264 10-bit / 4:2:2 | Apple silicon | **yes** where the chip decodes it | Intel; NVIDIA Blackwell (MXF too) | no (declined; no software fallback, [G3](gaps.md#g3-10-bit-and-422-camera-media-needs-hardware)) | no | +| HEVC Main / Main 10 4:2:0 | yes | **yes** | yes | **yes** | **yes** (NVDEC 8/10; VA-API Main / Main 10) | +| HEVC 4:2:2 10-bit | Apple silicon | **yes** where supported | Intel; NVIDIA Blackwell | no | no | +| VP9 profile 0 / 2 | — | software | — | **yes** | software (VA-API VP9 not yet) | +| AV1 main 8 / 10 | — | software | yes | **yes** | software | +| ProRes | Apple silicon (M1 Pro / Max and later) | software (fast: 318 fps) | software | software | software | +| Field-coded H.264 | yes | no | yes | no | no | + +Measured (`cargo xtask bench --hw off|auto`): M4 Pro, CPU per 2160p frame H.264 119 → 4.2 ms, +HEVC 86 → 3.7 ms, 4K plays with no dropped frames at Full; Intel Iris Xe VA-API H.264 2160p +417 → 11.4 ms, HEVC Main 10 2160p 212 → 18.4 ms. Every decoded picture is read back to the CPU +and re-uploaded to wgpu (**no zero-copy** yet), which Premiere avoids. + +## Hardware encode + +| Codec | Premiere macOS | **Ours macOS** | Premiere Windows | **Ours Windows** | **Ours Linux** | +|---|---|---|---|---|---| +| H.264 8-bit | yes | **yes** (VideoToolbox, opt-in, no B-frames) | Intel QSV, NVENC, AMD | **NVENC only** (opt-in) | **NVENC only** | +| H.264 10-bit / 4:2:2 | yes (Apple silicon) | no | Intel | no | no | +| HEVC Main 8-bit | yes | **yes** (hardware-only format) | QSV, NVENC, AMD | **NVENC only** | no | +| HEVC Main 10 / HDR (PQ, HLG) | yes | **no** (tone-mapped to 8-bit SDR) | yes | **NVENC yes** (HDR10 SEI, `mdcv` / `clli`) | no | +| ProRes | Apple silicon | software only | software | software | software | +| AV1 | — | no | via plug-ins / newer GPUs | no | no | + +Measured: VideoToolbox H.264 1080p25 encode 200 fps on M1; a 9:29 timeline exported in 311 s +against 793 s with our software encoder (SSIM 0.990). NVENC HEVC round trip 48.9 dB luma PSNR +(8-bit), 71.1 dB (10-bit) on an RTX 5060. + +## GPU rendering + +| Feature | Premiere (Mercury Playback Engine: Metal, CUDA, OpenCL) | Ours (wgpu: Metal, D3D12, Vulkan) | % | +|---|---|---|---| +| Compositing, blend modes, transforms | GPU | **GPU** (#32), CPU compositor is the oracle | 90% | +| Accelerated video effects | ~82 of ~92 effects marked Accelerated (reference list `plan/premiere/10-effects-list.md`) | **34 effect ids** (`render::gpufx::GPU_EFFECTS`): colour, levels, blurs, sharpen, crop, transform, flips, vignette, video limiter, ASC CDL, channel mix, Lumetri basic / creative / vignette | ~40% | +| Lumetri curves, wheels, HSL secondary | GPU | CPU | 0% | +| Keys (Ultra Key), masks, distortions, transitions | GPU | CPU (masks have WGSL parity tests, not in the live path) | 10% | +| Export rendering on the GPU | yes | **yes** (#421; Export ▸ GPU Rendering, Auto): 2.4× (1080p) to 3.2× (4K) faster on effects-heavy timelines | 80% | +| Thumbnails on the GPU (26.0) | yes | CPU | 0% | +| Software adapter fallback | warns | silently renders everything on the CPU (#632) | — | +| Intel integrated graphics | supported | start-up crash on Intel UHD (#512) | — | + +## Devices and I/O + +| Hardware | Premiere | Ours | % | +|---|---|---|---| +| Audio interfaces | CoreAudio, ASIO, WASAPI; multichannel mapping | cpal (CoreAudio, WASAPI, ALSA); no ASIO; device reports #605, #602 | 60% | +| Voice-over microphone | yes | yes (punch-in); macOS privacy declaration missing (#659) | 80% | +| External video monitoring (Mercury Transmit, Blackmagic DeckLink, AJA) | yes | no | 0% | +| Second computer monitor full-screen playback | yes | full screen inside the app window; no separate-display output or floating windows on other screens (#466) | 40% | +| HDR / EDR display | macOS EDR, Windows HDR | no (SDR display of HDR sequences) | 0% | +| Control surfaces (EUCON, Mackie HUI / MCU) | yes | no | 0% | +| LTC timecode input | yes | no | 0% | +| Tape / deck control | legacy | no | n/a | + +## Summary + +**Hardware dimension: ~45%, 120–200 h.** Ahead of Premiere: hardware decode on Linux (VA-API, +NVDEC) and FreeBSD builds. Behind, by user impact: zero-copy upload; GPU effects beyond 34 +(Lumetri curves / wheels / HSL, keys, masks, transitions); Quick Sync and AMD encoders on Windows; +VideoToolbox Main 10 HDR encode; 10-bit / 4:2:2 decode off macOS; HDR display; external video +I/O and control surfaces (need hardware we don't own). Calibration: one decode backend ~3–5 h, +one encoder ~4–8 h ([target-app-parity.md](target-app-parity.md#calibration)). + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | Created from ROADMAP.md's performance-and-hardware row and `crates/platform/README.md`; added Linux NVDEC (#549), GPU export and Lumetri on the GPU (#421), Premiere's per-platform hardware list, devices and I/O | diff --git a/docs/localization-parity.md b/docs/localization-parity.md new file mode 100644 index 00000000..a4a18386 --- /dev/null +++ b/docs/localization-parity.md @@ -0,0 +1,65 @@ +# Localization parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (new file: per-language status measured from the catalogs; replaces ROADMAP.md's "Interface localization" section) · **Target:** Adobe Premiere Pro 2026 (26.5.2) + +Per-language status of FilmCraft's interface. Premiere 26.5.2 ships 10 interface languages (the +installed bundle's `.lproj` folders): German, English, Spanish, French, Italian, Japanese, Korean, +Portuguese (Brazil), Russian, Simplified Chinese. + +## How it works + +Edit ▸ Language (also Settings ▸ General, `general.interfaceLanguage`) switches the interface and +persists across restarts; System Language (the default) follows the OS's or browser's preferred +languages and falls back to English. Commands `app.language.` +are reachable over the control channel. One catalog per language, +`crates/ui-egui/src/i18n/.tsv` (format in the header of `es.tsv`); UI code wraps strings in +`tl!("…")` / `tlf!("…{name}", name)`, and registry names (effects and their parameters, settings, +commands, panels, workspaces, presets) are translated where they are drawn. Searches match the +displayed labels as well as English sources. Tests fail when a `tl!` literal, a menu label or a +registry name has no Spanish entry; `zh-cn.tsv` must also cover every `tl!` literal and menu +label; `ja.tsv` has the same entries but is not yet required by tests. Engine error messages, the +control channel, the CLI and MCP stay English. + +Fonts come from [craft-fonts](https://github.com/storytold/craft-fonts) (`CRAFT_FONTS_DIR`, all +official releases) or an installed system face; without one, switching to Japanese or Chinese is +refused with a message. Simplified Chinese is picked for `zh`, `zh-CN`, `zh-SG`, `zh-Hans`, not +Traditional locales. Ukrainian uses the bundled Cyrillic interface fonts. Vertical text in titles +is supported. The interface (egui) does **not** shape complex scripts or lay out right to left; +the title engine (`crates/text`, harfrust + bidi) does, but Arabic titles are reported broken (#395). + +## Status + +Measured 2026-10-10 by counting entries in each catalog. The denominator is the union of all +catalogs' sources (**3,498 strings**); strings not yet wrapped in `tl!` and engine messages are +not in it, so real coverage is somewhat lower than shown. Estimates calibrated from #449 (Japanese, +whole interface, one PR) at ~4–8 h per language for the catalog plus tests. + +| Language | Code | UI strings | Dialogs / tooltips / help | Script support | Native review | Status | To `full` | +|---|---|---|---|---|---|---|---| +| English | en | 3,498 (100%, source) | yes; no help docs in-app | Latin | n/a | **full** | — | +| Simplified Chinese | zh-cn | 3,485 (99.6%) | dialogs, Settings, effects, shortcuts, status; engine errors English | CJK font fallback; system IME via winit | not recorded | partial | 6–10 h + review | +| Spanish | es | 3,496 (99.9%) | as above | Latin | not recorded | partial | 4–8 h + review | +| Hindi | hi | 0 (0%) | — | **no Devanagari shaping in the interface** | — | none | 30–50 h (shaping in UI + catalog + fonts) | +| Arabic | ar | 0 (0%) | — | **no RTL layout, no shaping in the interface**; titles broken (#395) | — | none | 40–70 h | +| French | fr | 0 (0%) | — | Latin | — | none (requested #498) | 6–10 h + review | +| Portuguese (Brazil) | pt-br | 348 (9.9%) | menus only | Latin | not recorded | menus only | 5–9 h + review | +| Indonesian | id | 0 (0%) | — | Latin | — | none | 6–10 h + 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 | 0 (0%) | — | Latin | — | none (requested #490) | 6–10 h + review | +| Korean | ko | 0 (0%) | — | needs Hangul fonts in craft-fonts and IME testing | — | none (requested #508) | 10–20 h + review | +| Vietnamese | vi | 0 (0%) | — | Latin with stacked diacritics (font coverage to check) | — | none | 6–12 h + review | +| *Other shipped:* Ukrainian | uk | 351 (10.0%) | menus only | Cyrillic (bundled) | not recorded | menus only | 5–9 h | + +Premiere also ships Italian and Russian, which we don't (6–10 h each). + +**Localization dimension:** 4 of the 12 key languages near-complete (English, Spanish, Japanese, +Simplified Chinese), 1 menus-only, 7 none; against Premiere's 10 languages we cover 4 well and 1 +partly. **~35%, 150–230 h** for all twelve to `full` plus Italian and Russian, of which the script +work (RTL and complex shaping in the egui interface) is the largest and least parallel part. +Every language needs native-speaker review (human). + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 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/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 00000000..3484e30a --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,91 @@ +# Roadmap detail + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (new file: milestones moved from ROADMAP.md, Current focus rewritten from the 2026-10-10 re-measure, beta milestones added) · **Target:** Adobe Premiere Pro 2026 (26.5.2) + +Forward-looking plan: milestones with status, the current focus and what's next, with estimates in +Opus 5.5 agent-hours (calibration in [target-app-parity.md](target-app-parity.md#calibration)). +The one-page summary is [ROADMAP.md](../ROADMAP.md); the ranked work list is [gaps.md](gaps.md). +There is no `cargo xtask scorecard` or `cargo xtask parity` yet ([G7](gaps.md#g7-no-measured-parity-tool-in-ci)). + +## Current focus + +In order. Each item links to its gap entry, which says what "done" means. + +1. **Stop launch crashes and add pull-request CI** ([G4](gaps.md#g4-crashes-at-start-up-and-no-test-ci-on-pull-requests)): `cargo xtask ci` on macOS, Windows and Linux for every PR; old-macOS and Intel-GPU start-up. 40–80 h. +2. **Open `.prproj`** ([G2](gaps.md#g2-premieres-prproj-cannot-be-opened)): the beta blocker. 60–120 h. +3. **10-bit / 4:2:2 camera media in software** ([G3](gaps.md#g3-10-bit-and-422-camera-media-needs-hardware)). 30–50 h. +4. **Fidelity harness against Premiere** ([G1](gaps.md#g1-nothing-compares-our-output-with-premieres)) and **`cargo xtask parity`** ([G7](gaps.md#g7-no-measured-parity-tool-in-ci)): turns estimates into measurements. 50–90 h. +5. **Real-world media corpus and workflow tests** ([G6](gaps.md#g6-no-real-world-media-corpus-or-workflow-acceptance-tests)). 40–70 h. +6. **Hardware acceleration, continued** (#30, [G5](gaps.md#g5-hardware-acceleration-is-partial)): zero-copy upload, Lumetri curves / wheels / HSL and keys on the GPU, VideoToolbox Main 10, Quick Sync / AMF encoders. 120–200 h. +7. **UI fidelity issues** ([G8](gaps.md#g8-ui-fidelity-monitor-handles-effect-controls-docking)): Program monitor transform handles, Effect Controls, docking. 120–200 h. + +## Alpha gate + +Premiere's core daily workflows, checked end to end on the main platform (macOS, Apple silicon), +including saving the work as `.fcproj` and reopening it (atomic saves, schema migrations, +auto-save and crash recovery; migration tests). Strict reading: a "partial" that stops the +workflow would make FilmCraft pre-alpha. + +| Workflow | Works end to end? | Evidence | Hours to pass | +|---|---|---|---| +| Ingest and organize media (import camera / phone / screen files, bins, metadata, proxies) | partial, not blocking | H.264 / HEVC / ProRes / DNx / MXF / AVCHD / MKV import with bit-exact decoders, Media Browser, bins, proxies, Link Media. Gaps: H.264 10-bit / 4:2:2 (Sony XAVC S-I, Canon XF-AVC) opens nowhere and HEVC 4:2:2 only through VideoToolbox (#626, [G3](gaps.md#g3-10-bit-and-422-camera-media-needs-hardware)); E-AC-3 (#647), AVI (#598), camera RAW | 0 (G3 is a beta item, 30–50 h) | +| Assemble and trim a sequence (insert / overwrite, ripple / roll / slip / slide, razor, J/K/L, multicam) | yes | Edit algebra with undo tests, Trim Monitor, Premiere keyboard preset, multicam with audio sync; UX issues (#683, #648, #668) slow it down but don't stop it | 0 | +| Colour correct and grade (Lumetri, LUTs, scopes, colour management) | yes | Lumetri complete with CPU / WGSL parity tests, all Lumetri Scopes, LUT import, HDR export signalling checked with ffprobe | 0 | +| Mix audio (levels, automation, Essential Sound, loudness) | yes on macOS | Mixer graph, automation, 53 effects, BS.1770 loudness matching ffmpeg; device reports are Windows / Linux (#605, #617) | 0 | +| Titles and captions | yes | Text engine, graphic clips, templates, SRT / VTT / SCC / MCC / STL / TTML import-export, burn-in; Arabic titles broken (#395) | 0 | +| Deliver (export H.264 / HEVC / ProRes / DNxHR / MXF with presets, queue) | yes | Own H.264 + AAC encoder, ProRes, DNxHR, MXF, hardware HEVC on macOS, ffprobe-checked exports; bitrate target miss (#371) | 0 | + +**Passes:** every core workflow runs end to end on macOS and the project saves and reopens, so +FilmCraft stays **alpha**; the one partial (10-bit / 4:2:2 camera codecs) blocks some cameras' +originals, not the workflow, and is on the beta list. Interchange with Premiere's own `.prproj` +is a beta requirement, not an alpha one (sequences come over as FCP7 XML, AAF or OTIO). + +## Beta milestones + +Beta = ready for real work ≥ ~75% and `.prproj` opens reliably ([target-app-parity.md](target-app-parity.md#stage)). + +| # | Milestone | Status | Est. | +|---|---|---|---| +| B1 | PR CI on three OSs + launch-crash fixes | ⬜ | 40–80 | +| B2 | `.prproj` import (sequences, clips, transitions, markers, bins, common effect parameters) | ⬜ | 60–120 | +| B3 | Camera media: H.264 High 10 / 4:2:2, HEVC 4:2:2, E-AC-3, AVI, multi-track audio confirmed | 🟡 (multi-stream audio #361) | 50–90 | +| B4 | Fidelity harness + `cargo xtask parity` + media corpus + workflow tests | ⬜ | 90–160 | +| B5 | Hardware: zero-copy, more GPU effects, Windows vendor encoders, VideoToolbox Main 10 | 🟡 | 60–100 | +| B6 | UI fidelity top issues and Windows / Linux runtime | 🟡 | 90–160 | +| B7 | AAF / OMF validated in Avid and Pro Tools; XML import speed | ⬜ | 20–40 | + +## Milestones (history and remaining) + +Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining agent-hours. + +| # | Milestone | Status | Done | Remaining | Est. | +|---|---|---|---|---|---| +| 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 | +| 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 | +| M7 | Audio | 🟡 | Mixer graph (tracks → submixes → Mix, pre/post-fader inserts and sends, latency-compensated, sample-accurate, ~6× realtime for 24 tracks × 3 effects on one core), Audio Track Mixer + Audio Clip Mixer panels, track automation (Off/Read/Latch/Touch/Write, recorded live while playing, thinned to keyframes, timeline lanes with pen editing), solo/solo-safe, channel mapping basics, peak + BS.1770 loudness meters (match ffmpeg), DSP crate with 16 clip/track effects, Audio Gain (set/adjust/normalize), Constant Power / Constant Gain / Exponential Fade, Essential Sound (types, Loudness auto-match, Repair incl. DeEss/DeReverb, Clarity, Creative, Ducking keyframes, presets; full Dialogue chain 22× realtime) | Music duration remix, ML speech enhancement, 5.1 panner and multichannel buses, voice-over record, effect editor windows (EQ curve), remaining effects (multiband, convolution reverb), clip-mixer automation recording | 6–9 | +| M8 | Colour | 🟡 | Lumetri: basic, creative + looks, RGB & hue curves, wheels, HSL secondary, vignette, section bypass; Input LUT / Look LUT (.cube 1D/3D/shaper, .3dl; tetrahedral CPU + WGSL; project LUT library; built-in camera conversions); Colour Match (Oklab tonal-range statistics, skin protection, solved in Lumetri wheels); colour management: Rec. 709 / Rec. 2100 PQ / HLG working spaces + wide gamut, Interpret Footage colour space (S-Log3, V-Log, Canon Log 2/3, LogC3/4, Apple Log, D-Log from published specs), metadata auto-detect, BT.2390 tone mapping, gamut mapping, HDR export signalling (VUI/colr/mdcv/clli/SEI, ffprobe-verified); Lumetri Scopes: Vectorscope YUV (75/100 % targets, skin-tone line) and HLS, Histogram, Parade (RGB/YUV/RGB-White), Waveform (RGB/Luma/YC/YC no Chroma), presets, Rec. 601/709/2100, 8-bit/float/HDR (cd/m²) scales, multi-scope grid, `scopes.read` | HDR-aware Lumetri maths, macOS EDR monitors, mastering metadata → tone-map peak, D-Log M (no published formula), HSL Secondary refine | 3–5 | +| M9 | More codecs | 🟡 | ProRes decode+encode, AAC decode+encode, HEVC Main/Main 10 decoder (bit-exact on 41 fixtures, ~225 fps 1080p), VP9 decoder (profiles 0–3, 8/10/12-bit, bit-exact on 50+ fixtures; WebM/MKV `V_VP9` and MP4 `vp09` import with key-frame-checked seeking), Matroska/WebM import (H.264/HEVC/VP9/ProRes/MJPEG + AAC/Opus/FLAC/MP3/Vorbis/PCM), Opus decoder (SILK/CELT/hybrid, 5.1/7.1 multistream; all RFC 8251 vectors range-exact; WebM/MKV/MP4), DNxHD/DNxHR decoder (all SMPTE ST 2019-1 CIDs, 8/10/12-bit, 4:2:2/4:4:4, interlaced, alpha; within IDCT precision of ffmpeg on 22 fixtures) + DNxHR LB/SQ/HQ/HQX/444 encoder and MOV `AVdh` export, AV1 decoder (Main profile, 8/10-bit, all intra/inter tools, loop filters, superres, film grain, intra BC, spatial layers; bit-exact vs libdav1d on 19 SVT fixtures + 22 libaom vectors; MP4 `av01` / WebM `V_AV1` import), MXF import (OP1a/OP-Atom; AVC with POC-ordered B pictures bit-exact, DNxHD/DNxHR, ProRes, PCM/AES3; MPEG-2 reported unsupported; index-table seeking, material package timecode), Ogg Opus/Vorbis (granule seeking, pre-skip, end trimming), image sequences, Broadcast WAV timecode | VP9 frame threading, AV1 threading/SIMD (~4 fps 1080p today), AV1 High/Professional profiles, H.264 High 10 / 4:2:2 and HEVC 4:2:2 in software (G3), E-AC-3, AVI (MPEG-2 decoder and hardware decode have landed) | 30–50 | +| M10 | Graphics & captions | 🟡 | Caption tracks (Subtitle/CEA-608/708/Teletext formats, track style), SRT/WebVTT/SCC import+export (frame-exact, property-tested), caption editing (add/split/merge/trim/move, sync-locked insert/extract), Text panel Captions tab, burn-in in Program monitor and export; text engine (`crates/text`: bundled + system fonts, harfrust shaping, bidi, line breaking, paragraph layout, glyph cache; 3-line 1080p title ≈ 0.15 ms warm); graphic clips with text + shape layers (fill, 2 strokes, background, shadow, keyframable transform), Type tool with on-monitor editing, shape/pen tools, Properties/Essential Graphics editor, align/distribute; graphics templates (own `.fcgt` format, 8 original built-ins, Browse tab with engine-rendered thumbnails, export / install / apply / editable properties; never reads .mogrt), rolls/crawls with ease and pre/postroll, responsive pins and intro/outro, per-character styles, Upgrade Caption to Graphic, source graphics, Replace Fonts; MCC (608+708), EBU STL, TTML/IMSC1, DFXP (frame-exact, property-tested) | 608/708 embedding in video streams, speech-to-text | 12–18 | +| M11 | Interchange & project management | ✅ | `.fcproj` schema versions + migrations, atomic saves, Save a Copy/Revert, auto-save ring + crash-recovery journal (Preferences ▸ Auto Save, recovery prompt), FCP7 XML, FCPXML, EDL, OTIO; offline media (own slate) + Link Media (fingerprint-checked relink, folder remap, search, Align Timecode, Make Offline); proxies (ProRes Proxy/LT, H.264 ¼/½ background jobs, attach/detach/reconnect, monitor toggle, export full-res) + ingest (copy/transcode/proxies); Project Manager (collect, consolidate + transcode with handles, size estimate) | Rename media to clip names, image-sequence conversion, Media Browser-driven relink, smart (cross-drive) path tracking | 1–2 | +| M12–M16 | Multicam, web (WASM), platform, long tail | 🟡 | Multicam: audio sync (GCC-PHAT, sub-sample), Merge Clips, multi-camera source sequences, Multi-Camera view with live switching (1–9); L0–L4 crates compile to wasm32 | Web app shell (file access, WebCodecs, audio), scene detection, auto reframe, ~800 remaining commands and dialogs, performance hardening | 35–55 | +| HW | Hardware acceleration (#30) | 🟡 | Blend modes and 34 effect ids on the GPU incl. Lumetri basic / creative / vignette (#32, GPU2–GPU7); GPU export rendering (#421); decode: VideoToolbox (#33, #41), Media Foundation / D3D11 H.264 / HEVC / VP9 / AV1 (HW2, HW3), VA-API H.264 / HEVC (#358), NVDEC H.264 / HEVC (#549); encode: VideoToolbox H.264 / HEVC, NVENC H.264 (Windows, Linux) and HEVC incl. Main 10 HDR (#328, #360) | Zero-copy, remaining GPU effects, VideoToolbox Main 10, Quick Sync / AMF, VA-API VP9 / AV1 and encode | 120–200 | +| L10N | Localization | 🟡 | Spanish, Japanese, Simplified Chinese whole interface; Portuguese (Brazil), Ukrainian menus | Hindi, Arabic (RTL), French, Indonesian, German, Korean, Vietnamese, Italian, Russian; native review | 150–230 | + +## After beta + +Plugin hosting (VST3 / AU, then OpenFX; owner decision, [G11](gaps.md#g11-no-plugin-hosting)), +AI features with openly licensed models ([G12](gaps.md#g12-ai-features)), delivery formats +([G13](gaps.md#g13-delivery-formats)), localization ([G14](gaps.md#g14-localization-8-of-the-12-key-languages-missing)), +the web app shell ([G20](gaps.md#g20-the-web-app-shell)). + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | Alpha gate table added (craftrules core-workflow gate): six core workflows, all pass on macOS; stage stays alpha | +| 2026-10-10 | major | Created: milestone table moved from ROADMAP.md (M5 and M9 remaining work updated), HW and L10N rows, beta milestones B1–B7, Current focus from the re-measure | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md new file mode 100644 index 00000000..29b2f706 --- /dev/null +++ b/docs/target-app-parity.md @@ -0,0 +1,358 @@ +# Parity with Adobe Premiere Pro + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (full re-measure against Premiere Pro 2026 26.5.2; replaces the scorecard and estimate that lived in ROADMAP.md) · **Target:** Adobe Premiere Pro 2026 (26.5.2, macOS) + +The authoritative assessment of how close FilmCraft is to Premiere Pro, and how much work is left. +[ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) lists every shortfall one at a time. +The deep checklists are [file-format-parity.md](file-format-parity.md), +[codec-parity.md](codec-parity.md), [hardware-parity.md](hardware-parity.md), +[ui-parity.md](ui-parity.md) and [localization-parity.md](localization-parity.md). + +## Headline + +| | Value | Kind | +|---|---|---| +| **Feature breadth** | **~86%** | Partly measured: menus 92% (label diff, below), effects / transitions / audio effects 100% (unit tests); the other areas are estimated | +| **Ready for real work** | **~56%** (range 51–61%) | Estimated depth per area, combined with written weights ([formula](#three-readiness-numbers)) | +| **Mainstream practitioner** | **~55%** | Weekly editing areas only, with written discounts ([method](#mainstream-practitioner)) | +| **Essentials user** | **~60%** | Core features at default settings, with written discounts ([method](#essentials-user)) | +| **Stage** | **alpha** | ~19 points and ~400–700 h from beta, and beta also needs `.prproj` import ([why](#stage)) | +| **Remaining to beta** | **~400–700 Opus 5.5 agent-hours** | Estimated, calibrated below | +| **Remaining to full parity** | **~1,550–2,750 Opus 5.5 agent-hours** (to ~95%: ~1,450–2,600) | Estimated; ~70% parallelizes | +| Codebase (measured 2026-10-10) | 45 crates + 3 apps, ~343,000 lines of Rust, 2,520 `#[test]` functions, 1,098 commits, 268 merged PRs, 69 authors | `git`, `gh`, `grep` | + +The previous estimate (2026-10-05) was breadth ~87%, ready ~50–60%, and "~210–325 agent-hours to +100%". The breadth figure is one point lower now because this pass counts the file formats Premiere +lists and we lack (AVI, WMV, camera RAW, EXR / DPX / PSD stills, `.prproj`, DVD / Blu-ray / DCP +export) rather than only the formats we have. The hours went up because the old figure covered the +checklist only and left out plugins, AI, localization, `.prproj` and the depth work that 188 open +issues now document. That is new evidence, not drift: see [Calibration](#calibration). + +## What we measured against, and how + +- **Target:** Adobe Premiere Pro 2026, version **26.5.2** (Spotlight `kMDItemVersion` of the + installed `/Applications/Adobe Premiere Pro 2026`), plus Adobe Media Encoder 2026 for export. +- **Menus (measured).** The maintainers' native menu-bar dump of Premiere 26.5.2 + (`plan/premiere/menus.json`, taken 2026-09-30 through System Events; local only, never + committed) has 433 leaf items. Removing Adobe-cloud, account, help, macOS system and + recent-file entries leaves 375. A label match against the string literals of `crates/engine`, + `crates/ui-egui`, `crates/edit` and `crates/project` finds **338 of 375 (90.1%)**; of the 37 + misses, 9 are dynamic or system items (About, AutoFill, Dictation, Emoji, window names), so + in-scope coverage is **338 / 366 ≈ 92%**. Real misses: Spelling (2), Render and Replace / + Restore Unrendered, Enhance Speech toggle, Auto-Tag Audio Types, Auto Reframe Sequence, Generate + / Reassociate Source Clips, Project Shortcut, Refresh All Projects, Export Selection as Premiere + Project, 5 workspaces (Essentials, Metalogging, Prelude, Starter, Text-Based Editing), + Audio Clip / Track Effect Editor, Sequence Index, AI Models and Collaboration settings, UXP / + Exchange plug-in entries. A label match is presence only, and can over-count (a label that + exists in a different place); it is a lower bound on misses, not proof of behaviour. The script + is ad hoc; turning it into `cargo xtask parity` is gap [G7](gaps.md#g7-no-measured-parity-tool-in-ci). +- **Effects (measured by tests).** `premiere_26_video_effects_catalogue`, + `premiere_audio_set_is_complete_and_foldered` and `premiere_26_transition_tree` check the + catalogue against Premiere's Effects panel: video effects 93/93 (+Legacy, Obsolete), audio + effects 53/53, video transitions 84/84 (+21 Legacy), audio transitions 3/3. +- **The installed app (file names and listings only, per [AGENTS.md](../AGENTS.md) §2; nothing + inside the bundle was opened or read).** `Contents/Resources/*.lproj`: de, en, es, fr, it, ja, + ko, pt, ru, zh_CN (10 UI languages). `Contents/MediaIO/systempresets/` folder names are + four-character exporter / codec codes: JPEG, QuickTime, MP3, PNG, Windows Media, AIFF, AVI, + BMP, DPX, TIFF, Targa, WAV, HEVC, MPEG-2 / DVD / Blu-ray, MXF (JPEG 2000, DNx, P2, XDCAM HD, + AS-10, AS-11), MP4 / AAC / H.264 / H.264 Blu-ray, FLV, uncompressed AVI, DCP, GIF, raw PCM, + OpenEXR. `Contents/Frameworks` names show what it decodes natively: ARRIRAW (ARRI Image SDK), + RED R3D, Sony RAW, Canon RAW (`CrmSdk`), Codex HDE, DNx SDK, JPEG XS, JPEG 2000 (Kakadu), + DV100, MPEG-4 Part 2, OMF (`Pro4OMF`), AAF, LTC timecode, C2PA content credentials, and an ONNX + runtime for its AI features. `PlugIns` lists OpenEXR, Radiance and RED / MXF XMP handlers. +- **Our side:** this repository on `origin/main` at `98ba9ac` (2026-10-10): code, crate READMEs, + tests, `docs/performance.md` benchmarks, and the 188 open / 118 closed GitHub issues (user + reports from releases 0.2–0.5). +- **Public documentation:** Adobe's "What's new" and release notes for 26.0–26.3 (Object Mask, + redesigned mask tools, R3D NE, OpenTimelineIO, GPU thumbnails, 4:2:2 10-bit hardware decode on + NVIDIA Blackwell), and Adobe's published lists of supported file formats and GPU / hardware + acceleration. +- **Not done in this pass:** running Premiere side by side on shared projects. No rendering or + behaviour of ours is compared with Premiere's output automatically yet ([G1](gaps.md#g1-nothing-compares-our-output-with-premieres)). + +## Weights + +Feature areas carry 70% of "ready for real work", cross-cutting qualities 30%. Weights reflect +what a working editor uses daily: the timeline first, then media, effects and panels. + +| Group | Weight | +|---|---| +| Editing, timeline, trimming, multicam | 20% of features | +| Effects and transitions | 12% | +| Media import and codecs | 12% | +| Audio | 10% | +| Panels and UI fidelity | 10% | +| Colour | 8% | +| Graphics, titles, captions | 8% | +| Export and delivery | 8% | +| Project files and interchange | 8% | +| Preferences and project management | 4% | +| *Cross-cutting:* hardware and performance | 10% of overall | +| Stability | 8% | +| Platforms | 5% | +| Plugins and ecosystem | 5% | +| AI features | 2% | + +Feature breadth weights the same ten feature areas only (cross-cutting qualities have no +"exists / doesn't" count). + +## By feature area + +Breadth = does it exist. Ready = depth, correctness, fidelity, robustness. Hours = Opus 5.5 +agent-hours to close the area to Premiere, sequential. + +| Area | Weight | Breadth | Ready | Hours | Evidence | Detail | +|---|---|---|---|---|---|---| +| Editing, timeline, trimming, multicam | 20% | ~90% | ~70% | 80–140 | Edit algebra, trim modes, Trim Monitor, multicam with audio sync, Premiere default keyboard (143 commands), Source playback and range dragging (10-08). Open: ripple-delete of a gap by column (#668), Backspace on gaps (#648), clip stretching (#653), deselect on empty space (#683), new tracks by dragging media (#483), Escape cancelling drags (#580), time remapping (#510), effects on a whole track (#623) | [ui-parity.md](ui-parity.md) | +| Effects and transitions | 12% | 100% (measured) | ~60% | 80–140 | Every effect exists; Warp Stabilizer is 2-D, Morph Cut and Auto Reframe approximate, optical flow renders as frame blending. 34 effect ids run on the GPU against ~82 of Premiere's ~92 marked Accelerated. No Object Mask (26.0, AI). Transform shutter angle adds no motion blur (#415); Ultra Key setting (#458) | [hardware-parity.md](hardware-parity.md) | +| Media import and codecs | 12% | ~75% | ~60% | 150–250 | Own bit-exact decoders for H.264, HEVC, VP9, AV1, ProRes, DNx, APV, MPEG-2, AAC, AC-3, Opus. But H.264 only 8-bit 4:2:0 and HEVC no 4:2:2 in software (most 10-bit cameras, #626); no camera RAW (R3D, ARRIRAW, BRAW, Sony RAW, Canon RAW, ProRes RAW, #345, #383); no E-AC-3 (#647), AVI (#598), WMV, DV, XAVC / P2 metadata; Sony start timecode (#460); WebM black (#432) | [codec-parity.md](codec-parity.md), [file-format-parity.md](file-format-parity.md) | +| Audio | 10% | ~88% | ~65% | 50–90 | Mixer graph, automation, all 53 effects, Essential Sound, 5.1, voice-over, Remix, multi-stream container audio (#361). Device and playback reports: crackle / cut-outs (#336, #410, #602, #605, #617); Enhance Speech is DSP, not a model; ALSA fallback fixed (#254) | | +| Panels and UI fidelity | 10% | ~88% | ~60% | 120–200 | All main panels. Missing: on-screen transform handles in the Program monitor (#639), Effect Controls keyframe zoom / divider / row lines (#640–#645), docking rearrangement (#493), multi-monitor windows (#466), filmstrip thumbnails (#614), marquee in Project panel (#578) | [ui-parity.md](ui-parity.md) | +| Colour | 8% | ~90% | ~70% | 40–70 | Lumetri complete incl. scopes, LUTs, colour management, HDR export. Lumetri on the GPU covers basic, creative and vignette only; HDR-aware Lumetri maths, macOS EDR display, D-Log M; a Lumetri report (#628) | | +| Graphics, titles, captions | 8% | ~85% | ~65% | 50–90 | Text engine with shaping and bidi, shapes, gradients (#514), our `.fcgt` templates (`.mogrt` refused by design), rolls / crawls, all caption formats. Arabic titles reported broken (#395); no 608/708 embedding in the video stream; speech-to-text off in release builds | | +| Export and delivery | 8% | ~80% | ~65% | 60–110 | H.264 (own encoder), HEVC through hardware only, ProRes, DNxHR, APV, MXF OP1a / OP-Atom, image sequences, GIF, WAV / AIFF, presets, queue, GPU export rendering (#421). Missing: AV1, MPEG-2 / DVD / Blu-ray, DCP, JPEG 2000 / XS MXF, AS-10 / AS-11, XDCAM HD, EXR / DPX / Targa sequences, MP3, AAC-only audio, ProRes 4444 in the UI (#342), interlaced, smart render; bitrate target missed (#371) | [file-format-parity.md](file-format-parity.md) | +| Project files and interchange | 8% | ~70% | ~45% | 90–160 | `.fcproj` with migrations, auto-save, recovery; FCP7 XML, FCPXML, AAF, OMF, EDL, OTIO, ALE. **Premiere's own `.prproj` cannot be opened** (the main beta blocker); AAF / OMF never validated in Media Composer / Pro Tools; XML import of a Premiere sequence took 97 s (#461) | [file-format-parity.md](file-format-parity.md) | +| Preferences and project management | 4% | ~80% | ~65% | 20–40 | 16 Settings categories, most wired; Project Manager, proxies, Link Media; Media Manager limits (#411); images as proxies (#451) | | +| **Features, weighted** | 100% | **~86%** | **~64%** | **740–1,290** | | | + +## By dimension + +| Dimension | Ready | Hours | Doc | +|---|---|---|---| +| Features (editing, effects, colour, audio, graphics, export settings, preferences) | ~66% | 350–620 | this file | +| UI / UX fidelity | ~60% | 120–200 | [ui-parity.md](ui-parity.md) | +| File formats (containers, project, interchange, captions, stills) | ~60% | 140–240 | [file-format-parity.md](file-format-parity.md) | +| Codecs (decode and encode, fidelity, camera media) | ~60% | 150–250 | [codec-parity.md](codec-parity.md) | +| Hardware acceleration (decode, encode, GPU effects, I/O devices) | ~45% | 120–200 | [hardware-parity.md](hardware-parity.md) | +| Localization (12 key languages) | ~35% of Premiere's set; 4 of 12 key languages near-complete | 150–230 | [localization-parity.md](localization-parity.md) | +| Performance (beyond hardware) | ~55% | 60–120 | [performance.md](performance.md) | +| Stability | ~45% | 60–120 | [gaps.md](gaps.md#g4-crashes-at-start-up-and-no-test-ci-on-pull-requests) | +| Platforms | ~60% | 50–90 | [gaps.md](gaps.md#g9-windows-and-linux-at-run-time) | +| Plugins and ecosystem | ~5% | 200–400 | [gaps.md](gaps.md#g11-no-plugin-hosting) | +| AI features | ~25% | 150–300 | [gaps.md](gaps.md#g12-ai-features) | +| **Total** | **~56%** | **~1,550–2,750** | | + +Dimension hours are disjoint (the feature row excludes UI, formats and codecs), so they add up. +Evidence for the cross-cutting rows: + +- **Hardware ~45%.** Hardware decode exists on all three desktop OSs (VideoToolbox; Media + Foundation / D3D11 incl. VP9 and AV1; VA-API and NVDEC for H.264 / HEVC on Linux). Hardware + encode: VideoToolbox H.264 / HEVC 8-bit; NVENC H.264 (Windows, Linux) and HEVC incl. Main 10 HDR + (Windows). Missing against Premiere: Intel Quick Sync and AMD encoders, 10-bit HEVC on macOS, + ProRes hardware, zero-copy upload, ~48 of Premiere's accelerated effects, external video I/O, + control surfaces. Detail: [hardware-parity.md](hardware-parity.md). +- **Performance ~55%.** 4K H.264 / HEVC play with no dropped frames with hardware decoding (M4 + Pro); GPU export is 2.4–3.2× faster on effects-heavy timelines; AV1 software decode is still + slow and 8K is not real time. Users on 0.5.0 report constant lag and ~100% CPU (#523, #525) and + a CPU fallback adapter rendering everything in software (#632). +- **Stability ~45%.** Never-crash rules enforced by lints; hostile-input fuzzing. But open + launch crashes on macOS 12 (missing VideoToolbox symbol, #468 / #661, #655), Intel UHD graphics + (#512), Windows 11 (#380, #582), a generic "keeps crashing" (#687), and **no workflow runs the + test suite on pull requests** (`.github/workflows` has release, packaging lint, FreeBSD and + Windows ARM64 builds only); `cargo xtask ci` runs locally only. +- **Platforms ~60%.** Releases for macOS, Windows (x64, ARM64), Linux (AppImage, Flatpak, deb, + tar) and FreeBSD; Linux is something Premiere doesn't run on at all. Runtime testing is still + mostly macOS; Windows and Linux reports dominate the issue list. The web build runs but its shell + is incomplete. No mobile. +- **Plugins ~5%.** No VST3 / Audio Units, no OpenFX, no Premiere / After Effects plug-in API, no + UXP panels (#325). Needs an owner decision (FFI). MCP, CLI and the control channel are ahead of + Premiere's scripting. +- **AI ~25%.** Speech to text exists (local Whisper) but is off in release builds; text-to-speech + narration with Kokoro-82M landed 10-10 (#278, beyond Premiere); scene edit detection; Enhance + Speech and Auto Reframe are DSP / heuristics. Missing: Object Mask (#406, #694), Generative + Extend, media-intelligence search, caption translation, auto colour. + +## Three readiness numbers + +| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates | +|---|---|---|---| +| Full target (ready for real work) | ~56% | ~1,450–2,600 (~70% parallelizes) | Plugin hosting, AI models, localization incl. RTL / Devanagari, camera RAW and delivery codecs, `.prproj`, hardware acceleration, UI fidelity | +| Mainstream practitioner | ~55% | ~500–850 (~65% parallelizes) | `.prproj` import (60–120), UI fidelity (120–200), stability and PR CI (40–80), 10-bit / 4:2:2 camera decode (30–50), playback / hardware (60–100), import and export gaps (60–100) | +| Essentials user | ~60% | ~180–350 (~60% parallelizes) | Launch crashes on Windows / Intel / older macOS (40–80), discoverability and timeline friction (60–110), playback lag (30–60), common import gaps (20–40), audio devices (15–30) | + +Hours per audience are calibrated like the rest (one hardware backend ~3–5 h, one encoder ~4–8 h, a +whole-interface language ~4–8 h, ~4–5 h per breadth point, depth work ~1.5–2× that). The +audiences nest, so essentials ≤ mainstream ≤ full: the essentials work is a subset of the +mainstream list, which is a subset of the full list. Needs a human: the plugin decision and AI +models (full only), native-speaker review (full only), Avid / Pro Tools validation (mainstream +and full), Windows / Intel test machines (all three). + + +All three use the depth ("Ready") estimates of the [feature areas](#by-feature-area); they differ in +which areas count and how they are discounted. The stage follows the first one and the +[core-workflow gate](roadmap.md#alpha-gate). + +### Ready for real work (full target) + +Written formula: 0.70 × (feature areas, weighted as in [Weights](#weights)) + the cross-cutting +rows, each weight × its percent. + +- Feature areas: 0.20×70 + 0.12×60 + 0.12×60 + 0.10×65 + 0.10×60 + 0.08×70 + 0.08×65 + 0.08×65 + + 0.08×45 + 0.04×65 = **63.1%**, × 0.70 = 44.2 points. +- Cross-cutting: hardware and performance 10% × 45, stability 8% × 45, platforms 5% × 60, + plugins 5% × 5, AI 2% × 25 = 4.5 + 3.6 + 3.0 + 0.25 + 0.5 = **11.9 points**. +- Total **56.0% ≈ ~56%** (range 51–61%). Earlier in this pass the + headline said ~55% (range 50–60%): the same inputs, rounded down by judgement instead of + computed. Nothing in the evidence changed; the number moved only because the arithmetic is now + written out. The stage is unaffected (still alpha; beta needs ~75% and `.prproj`). + +### Mainstream practitioner + +A typical professional editor's week: cut, trim, colour, mix, title, caption and deliver, on +their own machine, exchanging projects with Premiere users. Excluded: plugins, generative and cloud +AI, Team Projects / Productions, control surfaces and external I/O, other interface languages. +Depth here is judged on what that editor actually uses inside each area, so it is higher than the +full-area depth (e.g. the ~20 common effects rather than all 93 and their approximations). + +| Area | Weight | Depth | Why | +|---|---|---|---| +| Editing and trimming | 28% | 80% | Insert / overwrite, ripple / roll / slip / slide, razor, J/K/L, markers, multicam all work; gap deletion (#648, #668) and deselect (#683) are friction | +| Importing everyday footage | 14% | 65% | Phone and 8-bit camera H.264 / HEVC, ProRes, DNx, MXF open; 10-bit / 4:2:2 H.264 (Sony XAVC S-I, Canon) does not (#626); E-AC-3, AVI missing | +| Common effects and transitions | 9% | 75% | Lumetri, Transform, Crop, blurs, Cross Dissolve, keys exist; Warp Stabilizer is 2-D, Object Mask missing | +| Colour (Lumetri, scopes, LUTs) | 9% | 80% | Complete, parity-tested CPU / GPU; curves / wheels on CPU | +| Audio (levels, Essential Sound, loudness, ducking) | 11% | 75% | Complete; audio-device reports off macOS | +| Titles and captions | 7% | 75% | Text, shapes, templates, SRT / SCC / VTT; `.mogrt` not read by design | +| Export with presets | 9% | 75% | H.264, HEVC (hardware), ProRes, DNxHR, presets, queue; bitrate miss (#371) | +| Save, reopen, auto-save, relink, proxies | 4% | 80% | Atomic saves, migrations, recovery, Link Media | +| Playback speed on their machine | 9% | 65% | 4K H.264 / HEVC real time with hardware decode on macOS; lag / CPU reports (#523, #525) | +| **Weighted depth** | 100% | **74.8%** | | + +Discounts for what still stops real work: + +- **Interaction fidelity ×0.90:** about 40 open UI issues; no transform handles in the Program + monitor (#639); Effect Controls keyframe zoom / divider (#640–#645); docking (#493). +- **Stability on real machines ×0.92:** current macOS on Apple silicon is solid, but launch + crashes on macOS 12, Intel UHD and Windows 11 (#468, #512, #380, #582, #687), and no CI runs the + tests on pull requests. +- **File exchange with Premiere users ×0.88:** `.prproj` does not open (colleagues send + projects, not XML); AAF / OMF never validated in Avid / Pro Tools; FCP7 XML / OTIO do work. + +74.8 × 0.90 × 0.92 × 0.88 = **54.5% ≈ ~55%.** It is not higher than the full number, unlike most +Craft apps: the parts the mainstream number leaves out (plugins, AI, localization) weigh only +~12% in the full number, while the three discounts hit exactly what a working editor meets every +week. Raising it means fixing [G2](gaps.md#g2-premieres-prproj-cannot-be-opened), +[G3](gaps.md#g3-10-bit-and-422-camera-media-needs-hardware), +[G4](gaps.md#g4-crashes-at-start-up-and-no-test-ci-on-pull-requests) and +[G8](gaps.md#g8-ui-fidelity-monitor-handles-effect-controls-docking). + +### Essentials user + +Someone who imports phone or camera clips, puts them on a timeline, trims, adds a title, music and +a dissolve, and exports an MP4 with the default preset. Excluded: everything above that the +mainstream number excludes, plus advanced options, multicam, colour grading beyond presets, +interchange and pro formats. + +| Feature | Weight | Depth | Why | +|---|---|---|---| +| New project, import clips | 15% | 80% | Drag-and-drop and Import work for phone MP4 / MOV (H.264, HEVC); MKV multi-audio (#601), AVI (#598) fail | +| Put clips on the timeline, arrange | 15% | 85% | Drop to timeline (#517), snapping, tracks; new tracks by dropping missing (#483) | +| Trim, split, delete | 15% | 80% | Razor, trim, ripple delete; Backspace on gaps (#648) and deselect (#683) confuse | +| Undo / redo | 5% | 95% | Snapshot undo for every command | +| Titles | 10% | 75% | Type tool, templates; Arabic text broken (#395) | +| Transitions (cross dissolve) | 5% | 85% | Drag on, resize (#503) | +| Music and volume | 10% | 75% | Clip volume, fades, meters; device issues off macOS | +| Playback | 10% | 70% | Smooth on current Macs; lag / CPU reports on 0.5.0 (#523, #525) | +| Export MP4 with the default preset | 10% | 80% | Own H.264 + AAC, Quick Export | +| Save and reopen | 5% | 85% | `.fcproj`, auto-save, recovery prompt | +| **Weighted depth** | 100% | **80.0%** | | + +Discounts a beginner actually hits: + +- **Launch and stability ×0.88:** the beginner's machine is often a Windows laptop with Intel + graphics or an older Mac; open launch crashes there (#380, #413, #477, #512, #582, #655, #661, + #687). +- **Discoverability and UI clarity ×0.90:** a dense Premiere-style interface with no guided start; + reports about deselecting, deleting gaps and transitions controls (#683, #648, #577). +- **Opening files people send them ×0.95:** phone and web video mostly opens; AVI, E-AC-3 audio + (#647), FLAC-in-MP4 (#603) and some WebM (#432) do not. + +80.0 × 0.88 × 0.90 × 0.95 = **60.2% ≈ ~60%.** + +### User evidence + +Counted 2026-10-10 from all 323 GitHub issues and their 270 comments, excluding the four +repository collaborators (script in a private scratch folder; regex for praise and for "switched +from" phrases, then read by hand): + +- **Praise:** 31 distinct non-maintainer people across 37 issues used praise words. About a dozen + are explicit praise, e.g. "Great job making this go!" (#313), "I personally love everything done + on it" (#227), "impressive otherwise" (#208), "Great project guys" (#451). Several of them pair + the praise with a bug ("great project although it obviously has a lot of bugs", #455). +- **Switched from Premiere:** **0** reports of having switched. Two people describe testing it as + an alternative or hoping to leave Adobe (#411 "testing FilmCraft as an alternative workflow to + Adobe Premiere", #325 "finally moving away from adobe would be a god send"). +- **Open issues:** 202 open from non-maintainers; by title, ~93 are bug reports, of which **~64 + are core-path** (launch, import, playback, timeline, audio, export) and ~29 niche; ~109 are + feature requests or questions (Object Mask, plugin compatibility, languages, Android, + collaboration, node-based colour). + +This supports the discounts: people like the direction and use it, but core-path bugs outnumber +niche requests among the bug reports about two to one, and nobody reports having switched yet. + +## Stage + +**Alpha.** It passes the core-workflow gate ([roadmap.md](roadmap.md#alpha-gate): all six of Premiere's core daily workflows work end to end on macOS and the project saves and reopens). Core workflows run end to end (import, edit, trim, colour, mix, caption, deliver, +round-trip through XML / AAF), but ready-for-real-work is ~56%, below the ~75% beta bar, and the +standard's beta condition "opens and saves the target's main format reliably" fails outright: +FilmCraft cannot open a `.prproj` (it reads Premiere's FCP7 XML / AAF / OTIO exports instead). +Distance to beta: ~19 points of ready-for-real-work plus `.prproj` import, ~400–700 agent-hours: + +| To reach beta | Hours | +|---|---| +| `.prproj` import of mainstream projects (sequences, clips, effects' common parameters, markers) | 60–120 | +| Stability: launch crashes, PR CI running `cargo xtask ci` on all three OSs, crash triage | 40–80 | +| H.264 High 10 / 4:2:2 and HEVC 4:2:2 software decoding (camera media) | 30–50 | +| Real-world media corpus and scripted workflow acceptance tests | 40–70 | +| UI fidelity top issues (Program monitor handles, Effect Controls, docking, deselect) | 60–100 | +| Hardware: zero-copy, VideoToolbox Main 10, Quick Sync / AMF encode, more GPU effects | 60–100 | +| Windows / Linux runtime fixes, audio device issues | 30–60 | +| Container gaps users hit: E-AC-3, AVI, multi-track audio, still formats | 30–50 | +| Interchange validation (AAF / OMF in Avid and Pro Tools, XML speed) | 20–40 | +| Performance: lag / CPU reports, AV1 speed | 30–60 | +| **Total** | **~400–730** | + +## Calibration + +Hours are Opus 5.5 agent wall-clock hours, calibrated against this repo's history (`git log`, +`gh pr view` timestamps): + +- **Whole history.** First commit 2026-09-30; by 2026-10-10, 1,098 commits and 268 merged PRs + (243 since 10-05, most from community contributors' agents) took breadth from 0 to ~86%. The + 2026-10-01/02 blocks measured **~4–5 agent-hours per checklist point** with five parallel + agents (old ROADMAP). +- **A hardware decode backend** (one OS API, H.264 + HEVC, with parity tests): VideoToolbox #33 + (first commit 07:25, merged 11:26 the same day), VA-API #358 (+3,942 lines), NVDEC #549 (06:17 → + 11:04): **~3–5 h each**. +- **A hardware encoder per codec and OS:** NVENC HEVC #328 (+1,635 lines, 19:28 → 13:46 next day + including review), Main 10 HDR #360 (02:38 → 14:05): **~4–8 h each**. +- **GPU export rendering plus four GPU effects** (#421, 15 commits over ~23 h): **~8–12 h**. +- **A whole-interface language** (~3,500 strings, tests): Japanese #449 in one commit: + **~4–8 h**, plus native review (human). A menus-only language (Ukrainian #407, ~350 strings): + ~1 h. +- **A clean-room decoder from spec, bit-exact**: AV1 (09-30 → 10-01 night, several agents): ~15–30 h. +- **Depth work costs more per point than breadth.** Each remaining point of ready-for-real-work + is a bug class, a format edge or a fidelity comparison rather than a missing menu item; the + estimates apply ~1.5–2× the breadth rate. + +- **Wall-clock limits** (from the 2026-10-02 blocks): more than ~5 local agents slows everyone + (each worktree build is 6–7 GB; a full `cargo xtask ci` takes 20–60 min under load; the disk + filled twice), and one integrator merging and re-running CI is a bottleneck. With 4–5 parallel + agents plus an integrator, wall-clock is ~¼–⅓ of the agent-hours; community PRs now add + capacity beyond that. + +What parallelizes: almost everything by area (~70%). Serial: `.prproj` import before beta, +fidelity harness before fidelity numbers. **Needs a human:** the plugin-hosting decision (FFI, +licences), native-speaker review of every language, licensed or trained models for AI features, +hardware we don't own (Intel Quick Sync, AMD encoders, Blackwell 4:2:2, DeckLink / AJA I/O, +control surfaces), and validation in Avid Media Composer / Pro Tools. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | Readiness table: hours to ~95% for each audience (full ~1,450–2,600, mainstream ~500–850, essentials ~180–350); full number confirmed as an additive weighted sum (no change) | +| 2026-10-10 | minor | Added the three readiness numbers: full ready-for-real-work formula written out (~55% -> ~56%, same inputs, now computed rather than rounded down), mainstream practitioner ~55% and essentials user ~60% with written weights and discounts, and counted user evidence (31 people praising, 0 switch reports, ~64 core-path vs ~29 niche open bugs) | +| 2026-10-10 | minor | Stage checked against the new core-workflow gate (docs/roadmap.md#alpha-gate): passes, stays alpha | +| 2026-10-10 | major | Created from ROADMAP.md's scorecard and estimate; full re-measure against Premiere Pro 26.5.2 (menu label diff, installed-bundle listings, 188 open issues); breadth ~87 → ~86% (file formats counted), ready ~50–60% → ~55%, hours re-estimated to include plugins, AI, localization and `.prproj`; stage alpha | +| 2026-10-05 | major | Honest assessment added in ROADMAP.md (checklist ~87% vs ready ~50–60%) | +| 2026-10-02 | minor | Scorecard ~62% → ~72% → ~87% as the menu long tail, effects and codecs landed | diff --git a/docs/ui-parity.md b/docs/ui-parity.md new file mode 100644 index 00000000..611bb827 --- /dev/null +++ b/docs/ui-parity.md @@ -0,0 +1,38 @@ +# UI and interaction parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (new file: tools, handles, panels, keyboard and feel against Premiere Pro 26.5.2, with the open UI issues) · **Target:** Adobe Premiere Pro 2026 (26.5.2, macOS) + +How FilmCraft looks, feels and responds compared with Premiere: tools, on-screen handles, snapping, +nudging, modifier keys, shortcuts, drag behaviour, numeric entry, panels, context menus. Keyboard +detail is in [keyboard.md](keyboard.md), monitor view options in [monitors.md](monitors.md). +Reference screenshots and behaviour notes are maintainer-local (`plan/premiere/`, never committed). + +**UI / UX dimension: ~60% ready, breadth ~88%, 120–200 h** ([target-app-parity.md](target-app-parity.md#by-dimension)). +UI fidelity was compared with Premiere reference screenshots by the agents that built it; that +comparison has not been repeated independently. The ready figure is pulled down by the volume of +open interaction reports (about 40 of the 188 open issues). + +## Areas + +| Area | Premiere | Ours | % | Open issues | Est. | +|---|---|---|---|---|---| +| Menus | 366 in-scope menu items | ~338 present (label diff, 92%) | 92% | spelling, Render and Replace, 5 workspaces | 10–20 h | +| Keyboard | Premiere default keyboard + editor | Premiere-compatible preset (143 keyboard-only commands; 39 cloud / absent-panel keys skipped with reasons), FilmCraft / FCP / Avid presets, editor | 90% | Ctrl chords (#256 fixed), modifier hints on Windows (#270 fixed) | 5–10 h | +| Timeline editing tools | Selection, Track Select, Ripple / Rolling / Rate Stretch, Razor, Slip / Slide, Pen, Hand, Zoom, Type | all present, trim modes, Trim Monitor, dynamic trimming | 80% | deselect in empty space (#683), gap delete (#648, #668), clip stretch (#653), new tracks by dropping (#483), Escape cancels drag (#580), delete in timeline (#680, #404) | 30–50 h | +| Program monitor on-screen controls | Motion / Transform handles, mask handles, safe margins, guides | guides, rulers, safe margins, snapping, mask drawing; **no transform handles** (#639); Button Editor (+) does nothing (#431) | 55% | #639, #431 | 15–25 h | +| Effect Controls | property rows, keyframe area with zoom, divider, graphs, eyedroppers | rows, keyframes, value / velocity graphs, eyedropper (#518); missing keyframe zoom (#641, #645), draggable divider (#643), row lines (#640), Position curves (#448) | 60% | #412, #640–#645, #448 | 15–25 h | +| Panels and docking | 17 workspaces, dock / undock / float, drop zones | 11 workspaces, docking; rearranging windows reported missing (#493); multi-monitor floating windows (#466) | 60% | #493, #466 | 20–35 h | +| Project panel | List / Icon / Freeform, hover scrub, marquee | all three views, hover scrub, view presets; no marquee (#578), Freeform bins and pan (#579), bins can't be left on Windows (#587), duplicate imports allowed (#356) | 75% | #578, #579, #587, #456 | 10–20 h | +| Timeline display | filmstrip thumbnails, waveforms, track heights, renaming | clip thumbnails, waveforms, heights; no continuous filmstrip (#614), no track rename (#654), wheel scroll in audio area (#400) | 70% | #614, #654, #400 | 8–15 h | +| Transitions | drag, resize, alignment, edit in Effect Controls | drag / select / resize (#503); transition controls (#577) | 70% | #577 | 5–10 h | +| Numeric entry and scrubbing | scrubbable hot text, typed values | scrubbable values, timecode scrub-edit (#576); audio clip mixer values cannot be typed (#481) | 80% | #481 | 3–6 h | +| Drag and drop | OS files into Project / Timeline, clipboard paste | OS drops into the timeline (#517); no clipboard paste of images / files (#611) | 70% | #519, #611 | 5–10 h | +| Theme and appearance | dark / light, brightness | dark / light / system (#505, #555) | 85% | macOS window controls (#637), Wayland title bar (#433), X11 scale (#457) | 5–10 h | +| Context menus | per panel and per item | most panels; check against Premiere not done | 70% | — | 5–10 h | +| Accessibility | screen reader partial, keyboard navigation | keyboard navigation; every widget has an automation id; no screen-reader support | 30% | — | 15–30 h | + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | Created: UI areas against Premiere 26.5.2, menu label diff, keyboard parity from keyboard.md, open UI issues itemized | From 62928e9db21d776337154d09e746374f40993de3 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 20:11:00 +0200 Subject: [PATCH 084/144] #709: --gpu-rendering takes a space-separated value Problem: `--gpu-rendering` was missing from the CLI's list of value-taking options, so `export --gpu-rendering off out.wav` stored the option without a value and turned `off` into the output path (or dropped the setting silently when the option came after the filename). Fix: list `--gpu-rendering` among the valued options, as documented in the CLI help. Co-Authored-By: Claude Opus 5.5 --- apps/filmcraft-cli/src/args.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/filmcraft-cli/src/args.rs b/apps/filmcraft-cli/src/args.rs index bac68feb..aaa95420 100644 --- a/apps/filmcraft-cli/src/args.rs +++ b/apps/filmcraft-cli/src/args.rs @@ -19,6 +19,7 @@ const VALUED: &[&str] = &[ "--end", "--data-dir", "--settings", + "--gpu-rendering", ]; #[derive(Debug, Default)] @@ -140,4 +141,14 @@ mod tests { let a = args("import -- --weird-name.mov a.mov"); assert_eq!(a.positionals, ["import", "--weird-name.mov", "a.mov"]); } + + #[test] + fn gpu_rendering_takes_a_space_separated_value() { + let a = args("export --gpu-rendering off out.wav --start 0 --end 0.1"); + assert_eq!(a.positionals, ["export", "out.wav"]); + assert_eq!(a.opt("--gpu-rendering"), Some("off")); + let b = args("export out.wav --gpu-rendering auto"); + assert_eq!(b.positionals, ["export", "out.wav"]); + assert_eq!(b.opt("--gpu-rendering"), Some("auto")); + } } From 14f04c8c90ae528579585873d67f9c14f00e0a55 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 20:19:17 +0200 Subject: [PATCH 085/144] #714: an MP4 without an edit list starts at its first picture Problem: a video track with no edit list (x264 MP4 written without elst, fragmented MP4) kept its raw first PTS, the B-frame composition delay, as its origin. Media time zero repeated the first picture for that delay, and since the clip lasts the media duration, the last pictures never appeared in a sequence made from it. Fix: when the video track has no edit list, move every track back by the earliest start among the video and audio tracks, as ffmpeg does with an input's start time. Video-only files now start at their first picture; a file whose audio starts at zero keeps its A/V sync, and explicit edit lists (empty edits, delays) are untouched. Co-Authored-By: Claude Opus 5.5 --- crates/codecs/src/mp4.rs | 58 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/crates/codecs/src/mp4.rs b/crates/codecs/src/mp4.rs index 62f4f547..0ef36d35 100644 --- a/crates/codecs/src/mp4.rs +++ b/crates/codecs/src/mp4.rs @@ -170,7 +170,7 @@ impl Mp4Source { /// Open from a random-access reader: only the index is read now, samples on demand. pub fn open_reader(name: &str, reader: filmcraft_media::SharedReader) -> crate::Result { let bytes = crate::Src(reader); - let file = filmcraft_isobmff::open(&bytes).map_err(|e| CodecError::Container(e.to_string()))?; + let mut file = filmcraft_isobmff::open(&bytes).map_err(|e| CodecError::Container(e.to_string()))?; // A track needs samples, a sample description and a timescale to be playable (damaged // files can lack them: indexing `entries[0]` or dividing by the timescale used to panic). let playable = |t: &&filmcraft_isobmff::Track, kind| t.kind == kind && !t.samples.is_empty() && !t.entries.is_empty() && t.timescale > 0; @@ -281,7 +281,7 @@ impl Mp4Source { start_timecode, file_size: Some(bytes.0.len()), }; - let audios: Vec = atracks + let mut audios: Vec = atracks .iter() .zip(&info.audio_streams) .filter_map(|(&i, ainfo)| { @@ -300,6 +300,7 @@ impl Mp4Source { Some(Mp4Audio { track: i, state, starts, offset, preroll }) }) .collect(); + rebase_unedited_video(&mut file, vtrack, &mut audios); Ok(Self { info, bytes, file, vtrack, video: GopCache::new(explicit_color).with_rotation(rotation).with_crop(aperture), audios }) } @@ -605,6 +606,35 @@ pub fn opener(name: &str, bytes: Arc<[u8]>) -> Option, audios: &mut [Mp4Audio]) { + let Some(vi) = vtrack else { return }; + let Some(t) = file.tracks.get(vi).filter(|t| t.edits.is_empty() && t.timescale > 0) else { return }; + let Some(first) = t.samples.iter().map(|s| s.pts).min().map(|p| p.saturating_add(t.edit_offset)).filter(|&p| p > 0) else { return }; + let vts = i64::from(t.timescale); + let video_start = Tick::from_rational(first, 1, vts); + let audio_ts = |a: &Mp4Audio| file.tracks.get(a.track).map_or(1, |t| i64::from(t.timescale.max(1))); + // an audio track starts at its offset (a negative one only hides priming before zero) + let shift = audios.iter().fold(video_start, |m, a| m.min(Tick::from_rational(a.offset.max(0), 1, audio_ts(a)))); + if shift <= Tick::ZERO { + return; + } + for a in audios.iter_mut() { + let d = shift.to_rational_round(1, audio_ts(a)); + a.offset = a.offset.saturating_sub(d); + } + let v = if shift == video_start { first } else { shift.to_rational_round(1, vts) }; + if let Some(t) = file.tracks.get_mut(vi) { + t.edit_offset = t.edit_offset.saturating_sub(v); + } +} + /// How long a track plays. With an edit list that is the sum of its edits (ISO/IEC 14496-12 /// §8.6.6, movie timescale), as `tkhd` records it. The media duration (`mdhd`) runs on the decode /// timeline: it is longer than the presentation by the B-frame delay an edit skips (`media_time`, one @@ -700,6 +730,30 @@ mod tests { assert_eq!(frames(&Mp4Source::open("zero.mov", mov_with_edits(zero)).expect("open")), 4); } + /// #714: a video track without an edit list whose pictures carry a composition delay (B-frame + /// style, as x264 writes them with no `elst`) starts at its first picture. Media time zero + /// repeated picture 0 for the length of the delay and the last pictures never showed. + #[test] + fn unedited_composition_delay_starts_at_the_first_picture() { + let mut mux = Mp4Writer::new(std::io::Cursor::new(Vec::new()), WriterOptions::new(Brand::Mov)).expect("writer"); + let t = mux.add_track(TrackConfig::new(SampleEntry::prores(FourCc(*b"apch"), 64, 32), 25)).expect("track"); + for k in 0..4u16 { + let mut fr = filmcraft_prores::Frame::new(64, 32, filmcraft_prores::ChromaFormat::Yuv422, 10, false); + fr.y.fill(100 + 200 * k); + let data = filmcraft_prores::Encoder::new(filmcraft_prores::Profile::Hq, 64, 32).encode(&fr).expect("encode"); + mux.write_sample(t, WriteSample { data: &data, duration: 1, composition_offset: 2, is_sync: true }).expect("sample"); + } + let src = Mp4Source::open("delay.mov", mux.finish().expect("finish").into_inner().into()).expect("open"); + assert_eq!(src.info().duration, Tick::from_rational(4, 1, 25)); + let luma = |k: i64| { + let f = src.video_frame(FrameRequest::full(Tick::from_rational(k, 1, 25))).expect("frame"); + let l = f.luma8(); + l.iter().map(|&v| u32::from(v)).sum::() / l.len().max(1) as u32 + }; + let seen: Vec = (0..4).map(luma).collect(); + assert!(seen.windows(2).all(|w| w[0] < w[1]), "one picture per frame, in order: {seen:?}"); + } + /// Overwrite the big-endian u32 `skip` bytes after the first `fourcc` box type. fn patch_u32(b: &mut [u8], fourcc: &[u8; 4], skip: usize, v: u32) { let k = b.windows(4).position(|x| x == fourcc).expect("box") + 4 + skip; From d0617c849be4322325371229e05d50649e7e2ca5 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 20:20:59 +0200 Subject: [PATCH 086/144] #721: 5.1 AAC imports decode in WAV/SMPTE channel order Problem: our AAC decoder outputs channels in AAC element order (C, L, R, Ls, Rs, LFE for channel configuration 6) and PacketDecoder passed that through unchanged, while the mixer, panner, loudness and export all use WAV/SMPTE order (L, R, C, LFE, Ls, Rs). A 5.1 AAC import therefore put centre in L, L in R, R in C, the surrounds one slot early and LFE in Rs. The export path already reorders for the encoder; the decode path did not. Fix: PacketDecoder reorders AAC output for channel configurations 3-6 into WAV/SMPTE order, like the AC-3 decoder and Opus mapping family 1 already do. The Opus reorder now shares the same bounds-checked helper. Co-Authored-By: Claude Opus 5.5 --- crates/audio-dsp/src/channels.rs | 2 +- crates/codecs/src/audio.rs | 88 ++++++++++++++++++++++++++++++-- 2 files changed, 85 insertions(+), 5 deletions(-) diff --git a/crates/audio-dsp/src/channels.rs b/crates/audio-dsp/src/channels.rs index cda0f0c2..df0c534b 100644 --- a/crates/audio-dsp/src/channels.rs +++ b/crates/audio-dsp/src/channels.rs @@ -2,7 +2,7 @@ //! //! **Channel order.** 5.1 audio is always in the SMPTE / WAVE order **L, R, C, LFE, Ls, Rs** //! ([`L`], [`R`], [`C`], [`LFE`], [`LS`], [`RS`]); stereo is L, R; mono is one channel. Codecs with a -//! different native order (AAC: C, L, R, Ls, Rs, LFE) reorder at the encoder boundary. +//! different native order (AAC: C, L, R, Ls, Rs, LFE) reorder at the codec boundary. //! //! **Downmix** follows ITU-R BS.775 (Table 2, "3/2 source" rows), with `k = 1/√2` (−3 dB): //! diff --git a/crates/codecs/src/audio.rs b/crates/codecs/src/audio.rs index 5f7c02f4..e364402a 100644 --- a/crates/codecs/src/audio.rs +++ b/crates/codecs/src/audio.rs @@ -16,6 +16,7 @@ use crate::{CodecError, Result}; enum Inner { /// Our own AAC-LC decoder. `up` interpolates HE-AAC's core to the output rate (see [`Upsample2x`]). + /// Surround output is reordered to WAV/SMPTE order (see [`aac_to_wav_order`]). Aac { dec: Box, asc: Vec, up: Option> }, /// AAC whose configuration arrives with the first frame (ADTS / LATM). AacPending, @@ -52,6 +53,29 @@ fn vorbis_to_wav_order(channels: usize) -> Option<&'static [usize]> { }) } +/// Channel order for AAC surround (ISO/IEC 14496-3 Table 1.19, our decoder's element order: C, L R, +/// surrounds, LFE): output channel → decoded channel, giving the WAV/SMPTE order (L R C LFE Ls Rs) +/// used elsewhere, as the AC-3 decoder does and the AAC encoder's caller undoes on export. Only the +/// unambiguous channel configurations 3–6; 1, 2, 7 and PCE layouts keep the decoded order. +fn aac_to_wav_order(channel_config: u8) -> Option<&'static [usize]> { + Some(match channel_config { + 3 => &[1, 2, 0], + 4 => &[1, 2, 0, 3], + 5 => &[1, 2, 0, 3, 4], + 6 => &[1, 2, 0, 5, 3, 4], + _ => return None, + }) +} + +/// `channels` reordered by `order` (output channel → decoded channel). A decode whose channel count +/// does not match the order is returned unchanged. +fn reorder(mut channels: Vec>, order: Option<&[usize]>) -> Vec> { + match order { + Some(order) if order.len() == channels.len() => order.iter().map(|&c| channels.get_mut(c).map(std::mem::take).unwrap_or_default()).collect(), + _ => channels, + } +} + /// Duration of an Opus packet in 48 kHz samples from its TOC and frame count (RFC 6716 §3.1), /// without parsing frame lengths. For a multistream packet this is the first stream's TOC, which /// every stream shares. `None` for an empty or invalid (> 120 ms) packet. @@ -152,18 +176,17 @@ impl PacketDecoder { if let Some(up) = up { out = up.process(out); } + let out = reorder(out, aac_to_wav_order(dec.config().channel_config)); self.channels = out.len(); return Ok(out); } Inner::Opus { dec, order } => { // A corrupt packet is concealed like a lost one (keeps timing and decoder state). - let mut out = match dec.decode(Some(data)) { + let out = match dec.decode(Some(data)) { Ok(o) => o, Err(_) => dec.decode(None).map_err(|e| CodecError::Decode(e.to_string()))?, }; - if let Some(order) = order { - out = order.iter().map(|&c| std::mem::take(&mut out[c])).collect(); - } + let out = reorder(out, *order); self.channels = out.len(); return Ok(out); } @@ -811,6 +834,63 @@ mod he_aac_tests { } } +#[cfg(test)] +mod surround_order_tests { + use super::*; + + /// Power of `x` at `hz` (Goertzel), normalised by length. + fn power(x: &[f32], hz: f32, rate: f32) -> f32 { + let k = 2.0 * (std::f32::consts::TAU * hz / rate).cos(); + let (mut s1, mut s2) = (0.0f32, 0.0f32); + for &v in x { + let s0 = v + k * s1 - s2; + s2 = s1; + s1 = s0; + } + (s1 * s1 + s2 * s2 - k * s1 * s2) / x.len() as f32 + } + + /// #721: 5.1 AAC (configuration 6, coded C L R Ls Rs LFE) decodes to WAV/SMPTE order + /// L R C LFE Ls Rs, as the rest of FilmCraft and the AAC export path expect. + #[test] + fn aac_51_decodes_in_wav_order() { + let rate = 48_000u32; + // tones per speaker, in WAV order: L, R, C, LFE, Ls, Rs + let hz = [300.0f32, 400.0, 500.0, 60.0, 700.0, 800.0]; + let tone = |f: f32| (0..rate as usize).map(|i| 0.25 * (std::f32::consts::TAU * f * i as f32 / rate as f32).sin()).collect::>(); + let wav: Vec> = hz.iter().map(|&f| tone(f)).collect(); + // the encoder takes AAC element order (as the exporter feeds it): C L R Ls Rs LFE + let coded: Vec<&[f32]> = [2usize, 0, 1, 4, 5, 3].iter().map(|&c| wav[c].as_slice()).collect(); + let mut enc = filmcraft_aac::Encoder::new(filmcraft_aac::EncoderConfig::cbr(rate, 6, 384_000)).unwrap(); + let mut units = enc.encode(&coded); + units.extend(enc.flush()); + + let mut dec = PacketDecoder::aac(&enc.audio_specific_config(), rate).unwrap(); + let mut out = vec![Vec::new(); 6]; + for u in &units { + for (o, c) in out.iter_mut().zip(dec.decode(u, 0).unwrap()) { + o.extend(c); + } + } + assert_eq!(dec.channels, 6); + for (ch, samples) in out.iter().enumerate() { + let window = &samples[4800..38_400]; + let own = power(window, hz[ch], rate as f32); + for (other, &f) in hz.iter().enumerate().filter(|&(o, _)| o != ch) { + let p = power(window, f, rate as f32); + assert!(own > 100.0 * p, "channel {ch}: {} Hz {own} vs {f} Hz (channel {other}) {p}", hz[ch]); + } + } + } + + #[test] + fn reorder_leaves_mismatched_channel_counts_alone() { + let chans = vec![vec![1.0], vec![2.0]]; + assert_eq!(reorder(chans.clone(), aac_to_wav_order(6)), chans); + assert_eq!(reorder(vec![vec![0.0], vec![1.0], vec![2.0]], aac_to_wav_order(3)), vec![vec![1.0], vec![2.0], vec![0.0]]); + } +} + #[cfg(test)] mod packet_time_tests { use super::*; From bc5965e42b52d6886c32aea35b96ebd894310099 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 20:22:46 +0200 Subject: [PATCH 087/144] #710: --version works after global options Problem: `filmcraft-cli --demo --version` (or `--compact -V`, `--data-dir d --version`) failed with "missing subcommand" and exit 2. The version check only looked at the very first argument, so a preceding global option left `--version` to the option parser and no subcommand behind. Fix: parse the arguments first and treat `--version` anywhere, or `-V` in the subcommand position, as a version query (`Args::wants_version`), still before the hardware decoders are registered. `--help` keeps priority. Co-Authored-By: Claude Opus 5.5 --- apps/filmcraft-cli/src/args.rs | 16 ++++++++++++++++ apps/filmcraft-cli/src/main.rs | 13 +++++++------ apps/filmcraft-cli/tests/cli.rs | 10 ++++++++++ 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/apps/filmcraft-cli/src/args.rs b/apps/filmcraft-cli/src/args.rs index bac68feb..af531c50 100644 --- a/apps/filmcraft-cli/src/args.rs +++ b/apps/filmcraft-cli/src/args.rs @@ -60,6 +60,12 @@ impl Args { self.opts.iter().any(|(n, _)| n == k) } + /// A version query: `--version` anywhere, or `-V` in the subcommand position, so global + /// options may come first (`--demo --version`, `--compact -V`). + pub fn wants_version(&self) -> bool { + self.flag("--version") || self.pos(0) == Some("-V") + } + /// Command params from positionals `from..`: one JSON object, or `key=value` pairs /// (dotted keys nest: `color.r=1`). pub fn params_from(&self, from: usize) -> Result { @@ -135,6 +141,16 @@ mod tests { assert_eq!(a.params_from(2).unwrap(), json!({"a": {"b": [1]}})); } + #[test] + fn version_after_global_options() { + for s in ["--version", "-V", "--demo --version", "--compact -V", "--data-dir d --version", "export --version"] { + assert!(args(s).wants_version(), "{s}"); + } + for s in ["version", "--data-dir -V", "exec x -V", "-- --version"] { + assert!(!args(s).wants_version(), "{s}"); + } + } + #[test] fn double_dash_ends_options() { let a = args("import -- --weird-name.mov a.mov"); diff --git a/apps/filmcraft-cli/src/main.rs b/apps/filmcraft-cli/src/main.rs index b051d6cb..2d54c492 100644 --- a/apps/filmcraft-cli/src/main.rs +++ b/apps/filmcraft-cli/src/main.rs @@ -252,12 +252,6 @@ async fn main() { } async fn cli() { - if matches!(std::env::args().nth(1).as_deref(), Some("--version" | "-V")) { - outln!("filmcraft-cli {}", env!("CARGO_PKG_VERSION")); - return; - } - register_hardware_decoders(); - register_gpu_frame_renderer(); let a = Args::parse(std::env::args().skip(1)); // `--help` anywhere (`filmcraft-cli --help`, `filmcraft-cli export --help`) prints the reference: // the parser takes it as a flag option, so it never reaches the subcommand match. @@ -265,6 +259,13 @@ async fn cli() { out!("{HELP}"); return; } + // `--version` anywhere and `-V` as the subcommand, also after global options (`--demo --version`, #710). + if a.wants_version() { + outln!("filmcraft-cli {}", env!("CARGO_PKG_VERSION")); + return; + } + register_hardware_decoders(); + register_gpu_frame_renderer(); let Some(cmd) = a.pos(0) else { usage("missing subcommand") }; match cmd { "help" | "-h" => out!("{HELP}"), diff --git a/apps/filmcraft-cli/tests/cli.rs b/apps/filmcraft-cli/tests/cli.rs index 2736806e..8552cc64 100644 --- a/apps/filmcraft-cli/tests/cli.rs +++ b/apps/filmcraft-cli/tests/cli.rs @@ -24,6 +24,16 @@ fn help_and_usage_errors() { assert_eq!(cli(&["exec", "file.newBin", "notkv"]).status.code(), Some(2)); } +#[test] +fn version_after_global_options() { + let want = format!("filmcraft-cli {}\n", env!("CARGO_PKG_VERSION")); + for args in [&["--version"][..], &["-V"], &["version"], &["--demo", "--version"], &["--compact", "-V"], &["--demo", "version"]] { + let o = cli(args); + assert!(o.status.success(), "{args:?}: {}", String::from_utf8_lossy(&o.stderr)); + assert_eq!(String::from_utf8_lossy(&o.stdout), want, "{args:?}"); + } +} + #[test] fn long_help_matches_help_command() { let reference = cli(&["help"]); From fdb0ce944ddcc9c54041eecc8535706158a5e368 Mon Sep 17 00:00:00 2001 From: ReReddo <249398616+ReReddo@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:29:53 +0300 Subject: [PATCH 088/144] graphics: shape handles change the shape's Size, Shift keeps the ratio Dragging a handle of a rectangle, ellipse or polygon in the Program monitor changed the layer's Scale, and a corner scaled width and height alike, so a shape could not be made wider or taller from a corner. As in Premiere, the handles now change the shape's Size (Premiere's Path) and leave Scale alone: the dragged side or corner follows the pointer, the opposite one stays, and the anchor point is renumbered so that it and Position stay on the same spot of the picture. A corner moves freely; Shift keeps the proportions. A path has no Size, so it still stretches (and its corners follow the same Shift rule). The paragraph-text box and the shape box share the side-dragging code (drag_sides). Tests: graphics_handles_ui (a shape's handles change its Size and Shift keeps the ratio; a polygon's handles keep the opposite corner). --- crates/ui-egui/src/panels/graphics.rs | 121 ++++++++++++++------ crates/ui-egui/tests/graphics_handles_ui.rs | 64 ++++++++++- docs/graphics.md | 2 +- 3 files changed, 151 insertions(+), 36 deletions(-) diff --git a/crates/ui-egui/src/panels/graphics.rs b/crates/ui-egui/src/panels/graphics.rs index 09aead4f..34935384 100644 --- a/crates/ui-egui/src/panels/graphics.rs +++ b/crates/ui-egui/src/panels/graphics.rs @@ -186,7 +186,8 @@ fn handle_points(quad: &[Pos2; 4]) -> [Pos2; 8] { } /// Dragging handle `n` of `quad` from `start` to `cur`: the box grows towards the pointer while -/// the opposite corner / edge stays put. A corner scales both axes alike; an edge only its own. +/// the opposite corner / edge stays put. A corner scales each axis by the pointer's travel along +/// it (both alike with `keep_ratio`, Shift); an edge scales only its own. struct HandleScale { /// The handle opposite the dragged one, which does not move. pin: Pos2, @@ -195,10 +196,12 @@ struct HandleScale { uy: egui::Vec2, fx: f32, fy: f32, + /// Width and height scale apart (an edge, or a corner without Shift). + apart: bool, } impl HandleScale { - fn new(quad: &[Pos2; 4], n: usize, start: Pos2, cur: Pos2) -> Self { + fn new(quad: &[Pos2; 4], n: usize, start: Pos2, cur: Pos2, keep_ratio: bool) -> Self { let pts = handle_points(quad); let n = n.min(7); let pin = if n < 4 { pts[(n + 2) % 4] } else { pts[4 + (n - 4 + 2) % 4] }; @@ -209,14 +212,15 @@ impl HandleScale { if from.abs() < 1.0 { 1.0 } else { ((cur - pin).dot(dir) / from).max(0.01) } }; let (fx, fy) = match n { - 0..=3 => { + 0..=3 if keep_ratio => { let f = along((start - pin).normalized()); (f, f) } + 0..=3 => (along(ux), along(uy)), 4 | 6 => (1.0, along(uy)), _ => (along(ux), 1.0), }; - Self { pin, ux, uy, fx, fy } + Self { pin, ux, uy, fx, fy, apart: n >= 4 || !keep_ratio } } /// Where a screen point of the layer ends up. @@ -231,9 +235,29 @@ impl HandleScale { /// Premiere Pro 26 with the Program monitor at Fit. const NEAR_ANCHOR: f32 = 60.0; +/// Box `from` (`[x0, y0, x1, y1]`) with handle `n` moved by (`dx`, `dy`): that side or corner +/// follows, the opposite one stays, and the box keeps at least `min` across instead of flipping. +fn drag_sides(from: [f32; 4], n: usize, dx: f32, dy: f32, min: f32) -> [f32; 4] { + let [mut x0, mut y0, mut x1, mut y1] = from; + if matches!(n, 0 | 3 | 7) { + x0 = (x0 + dx).min(x1 - min); + } + if matches!(n, 1 | 2 | 5) { + x1 = (x1 + dx).max(x0 + min); + } + if matches!(n, 0 | 1 | 4) { + y0 = (y0 + dy).min(y1 - min); + } + if matches!(n, 2 | 3 | 6) { + y1 = (y1 + dy).max(y0 + min); + } + [x0, y0, x1, y1] +} + /// What dragging handle `n` of a layer from `start` to `cur` does, as Premiere Pro does it: point /// text scales about its anchor point, a paragraph-text box is resized (its text re-wraps at the -/// same size), and a shape stretches away from its opposite side. +/// same size), a shape's Size follows the handle (Scale untouched), and a path stretches away from +/// its opposite side. enum HandleDrag { Stretch(HandleScale), /// Scale both axes by `f` about the anchor point, which is at `anchor` on screen. @@ -248,36 +272,54 @@ enum HandleDrag { from: [f32; 4], tall: bool, }, + /// The shape's box becomes `rect` (in the layer's pixels; it was `from`, with Size `size`). + Size { + rect: [f32; 4], + from: [f32; 4], + size: (f32, f32), + }, } impl HandleDrag { - fn new(v: &LayerView, n: usize, start: Pos2, cur: Pos2) -> Self { + fn new(v: &LayerView, n: usize, start: Pos2, cur: Pos2, keep_ratio: bool) -> Self { let n = n.min(7); let quad = v.quad(); - let Some(t) = text_of(&v.spec) else { return Self::Stretch(HandleScale::new(&quad, n, start, cur)) }; + let (dx, dy) = match (v.to_local(start), v.to_local(cur)) { + (Some(a), Some(b)) => (b.0 - a.0, b.1 - a.1), + _ => (0.0, 0.0), + }; + let Some(t) = text_of(&v.spec) else { + let LayerContent::Shape(s) = &v.spec.content else { return Self::Stretch(HandleScale::new(&quad, n, start, cur, keep_ratio)) }; + // a path is its points, so it stretches; the other shapes are drawn from their Size + if s.shape == 3 { + return Self::Stretch(HandleScale::new(&quad, n, start, cur, keep_ratio)); + } + let from = v.local; + let mut rect = drag_sides(from, n, dx, dy, 1.0); + let (w, h) = (from[2] - from[0], from[3] - from[1]); + if keep_ratio && n < 4 && w > 0.0 && h > 0.0 { + // Shift: the side that moved further sets the scale of both; the opposite corner stays + let (fw, fh) = ((rect[2] - rect[0]) / w, (rect[3] - rect[1]) / h); + let f = if (fw - 1.0).abs() >= (fh - 1.0).abs() { fw } else { fh }; + let f = f.max(1.0 / w).max(1.0 / h); + if matches!(n, 0 | 3) { + rect[0] = rect[2] - w * f; + } else { + rect[2] = rect[0] + w * f; + } + if matches!(n, 0 | 1) { + rect[1] = rect[3] - h * f; + } else { + rect[3] = rect[1] + h * f; + } + } + return Self::Size { rect, from, size: s.size }; + }; if is_paragraph(t) { // a box without a height is as tall as its text let from = if t.box_height > 0.0 { [0.0, 0.0, t.box_width, t.box_height] } else { [0.0, v.local[1], t.box_width, v.local[3]] }; - let (dx, dy) = match (v.to_local(start), v.to_local(cur)) { - (Some(a), Some(b)) => (b.0 - a.0, b.1 - a.1), - _ => (0.0, 0.0), - }; - // the handle follows the pointer and the opposite side stays; the box does not flip - let min = (t.size * 0.5).max(1.0); - let [mut x0, mut y0, mut x1, mut y1] = from; - if matches!(n, 0 | 3 | 7) { - x0 = (x0 + dx).min(x1 - min); - } - if matches!(n, 1 | 2 | 5) { - x1 = (x1 + dx).max(x0 + min); - } - if matches!(n, 0 | 1 | 4) { - y0 = (y0 + dy).min(y1 - min); - } - if matches!(n, 2 | 3 | 6) { - y1 = (y1 + dy).max(y0 + min); - } - return Self::Resize { rect: [x0, y0, x1, y1], from, tall: t.box_height > 0.0 || !matches!(n, 5 | 7) }; + let rect = drag_sides(from, n, dx, dy, (t.size * 0.5).max(1.0)); + return Self::Resize { rect, from, tall: t.box_height > 0.0 || !matches!(n, 5 | 7) }; } // Every handle scales both axes alike. Only the pointer's travel along one side of the box // counts: along its width for the two side handles, along its height for all the others. @@ -305,12 +347,14 @@ impl HandleDrag { match self { Self::Stretch(h) => v.quad().map(|q| h.apply(q)), Self::Scale { anchor, f } => v.quad().map(|q| *anchor + (q - *anchor) * *f), - Self::Resize { rect: b, .. } => [(b[0], b[1]), (b[2], b[1]), (b[2], b[3]), (b[0], b[3])].map(|(x, y)| sp(&v.to_screen, x, y)), + Self::Resize { rect: b, .. } | Self::Size { rect: b, .. } => { + [(b[0], b[1]), (b[2], b[1]), (b[2], b[3]), (b[0], b[3])].map(|(x, y)| sp(&v.to_screen, x, y)) + } } } /// The `graphics.set` properties that carry out the drag. - fn props(&self, v: &LayerView, n: usize) -> Value { + fn props(&self, v: &LayerView) -> Value { let tr = &v.spec.transform; match self { Self::Stretch(h) => { @@ -322,8 +366,7 @@ impl HandleDrag { "scale_width": tr.scale.x * 100.0 * h.fx as f64, "position": [tr.position.x + delta.x, tr.position.y + delta.y], }); - if n >= 4 { - // an edge stretches one axis only + if h.apart { props["uniform_scale"] = json!(false); } props @@ -341,6 +384,18 @@ impl HandleDrag { } props } + Self::Size { rect, from, size } => { + // The box scales with Size about the layer's origin, so the origin moves to `o` (in + // the old layer pixels) to put the box at `rect`. The anchor point is renumbered so + // that it, the Position and the side that was not dragged stay where they are. + let f = |a: f32, b: f32| if b > 0.0 { a / b } else { 1.0 }; + let (fx, fy) = (f(rect[2] - rect[0], from[2] - from[0]), f(rect[3] - rect[1], from[3] - from[1])); + let o = (rect[0] - from[0] * fx, rect[1] - from[1] * fy); + json!({ + "size": [size.0 * fx, size.1 * fy], + "anchor": [tr.anchor.x - o.0 as f64, tr.anchor.y - o.1 as f64], + }) + } } } } @@ -561,7 +616,7 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra } DragKind::Handle(n) => { if let Some(v) = v { - let h = HandleDrag::new(v, n, d.start, cur); + let h = HandleDrag::new(v, n, d.start, cur, ui.input(|i| i.modifiers.shift)); painter.add(egui::Shape::closed_line(h.quad(v).to_vec(), Stroke::new(1.0, Color32::WHITE))); } } @@ -614,7 +669,7 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra } DragKind::Handle(n) if moved => { if let Some(v) = v { - let props = HandleDrag::new(v, n, d.start, cur).props(v, n); + let props = HandleDrag::new(v, n, d.start, cur, ui.input(|i| i.modifiers.shift)).props(v); actions.push(("graphics.set".into(), json!({"clip": v.clip.0, "layer": v.layer, "props": props}))); } } diff --git a/crates/ui-egui/tests/graphics_handles_ui.rs b/crates/ui-egui/tests/graphics_handles_ui.rs index 53879852..b7553926 100644 --- a/crates/ui-egui/tests/graphics_handles_ui.rs +++ b/crates/ui-egui/tests/graphics_handles_ui.rs @@ -1,6 +1,6 @@ //! Headless UI tests of the handles of graphic layers in the Program monitor: point text scales -//! about its anchor point, a paragraph-text box is resized, a shape stretches away from its -//! opposite side (docs/graphics.md). +//! about its anchor point, a paragraph-text box is resized, a shape's Size follows the handle +//! while its opposite side and anchor point stay (docs/graphics.md). //! //! With `FILMCRAFT_UI_SHOTS=` the tests also render the UI with wgpu and save PNGs there. @@ -130,6 +130,14 @@ impl Driver { self.frames(4); } + /// Press or let go of Shift, as a keyboard reports it (the control channel's drags carry + /// modifiers only on their press and release). + fn hold_shift(&mut self, on: bool) { + let m = if on { egui::Modifiers::SHIFT } else { egui::Modifiers::NONE }; + self.harness.input_mut().events.push(egui::Event::ModifiersChanged(m)); + self.frames(1); + } + fn drag_handle(&mut self, pre: &str, n: usize, dx: f64, dy: f64) { let h = self.handle(pre, n); self.drag(h, (dx, dy)); @@ -372,6 +380,58 @@ fn shape_corner_handles_scale_away_from_the_opposite_corner() { assert!(bl2.0 < bl.0 - 20.0 && bl2.1 > bl.1 + 5.0, "bottom-left follows the pointer: {bl:?} -> {bl2:?}"); } +/// Width and height of a layer's box in its own pixels. +fn box_size(l: &Value) -> (f64, f64) { + let b = &l["localBounds"]; + (num(&b[2]) - num(&b[0]), num(&b[3]) - num(&b[1])) +} + +#[test] +fn shape_handles_change_its_size_and_shift_keeps_the_ratio() { + let mut d = Driver::new(); + let pre = d.shape(); + let clip: u64 = pre.split('.').nth(2).unwrap().parse().unwrap(); + // a corner straight right: only wider, as in Premiere; the Size changes, not the Scale + let (tl, br, anchor) = (d.handle(&pre, 0), d.handle(&pre, 2), d.at(&pre, "anchor")); + let before = d.layer(clip); + d.drag_handle(&pre, 2, 60.0, 0.0); + let l = d.layer(clip); + assert_eq!((num(&l["scale"]), num(&l["scaleWidth"])), (100.0, 100.0), "{l}"); + let (w, h) = box_size(&l); + assert!(w > 510.0 && (h - 300.0).abs() < 1e-3, "wider, as tall: {w} x {h}"); + let (tl2, br2) = (d.handle(&pre, 0), d.handle(&pre, 2)); + assert!(near(tl, tl2), "top-left stays put: {tl:?} -> {tl2:?}"); + assert!(near(br2, (br.0 + 60.0, br.1)), "bottom-right follows the pointer: {br:?} -> {br2:?}"); + assert!(near(d.at(&pre, "anchor"), anchor), "the anchor point stays put"); + assert_eq!(l["position"], before["position"]); + + // with Shift both sides grow alike from the opposite corner + d.hold_shift(true); + d.drag_handle(&pre, 2, 40.0, 0.0); + d.hold_shift(false); + let l2 = d.layer(clip); + let (w2, h2) = box_size(&l2); + assert!(h2 > 310.0, "taller too: {w2} x {h2}"); + assert!((w2 / h2 - w / h).abs() < 1e-3, "same proportions: {w}x{h} -> {w2}x{h2}"); + assert!(near(d.handle(&pre, 0), tl), "top-left still stays put"); + assert_eq!(num(&l2["scale"]), 100.0); + // one undo step per drag + d.exec("edit.undo", json!({})); + assert_eq!(box_size(&d.layer(clip)), (w, h)); +} + +#[test] +fn polygon_handles_keep_the_opposite_corner() { + let mut d = Driver::new(); + let r = d.exec("graphics.newPolygon", json!({"sides": 5, "position": [800, 500], "size": [300, 300]})); + let pre = d.select(r["clip"].as_u64().unwrap()); + // a pentagon's box is not centred on the shape's origin; the top-left corner stays anyway + let (tl, br) = (d.handle(&pre, 0), d.handle(&pre, 2)); + d.drag_handle(&pre, 2, 50.0, 40.0); + assert!(near(d.handle(&pre, 0), tl), "top-left stays put"); + assert!(near(d.handle(&pre, 2), (br.0 + 50.0, br.1 + 40.0)), "bottom-right follows the pointer"); +} + #[test] fn shape_edge_handles_stretch_one_axis() { let mut d = Driver::new(); diff --git a/docs/graphics.md b/docs/graphics.md index 433d491b..fd04667d 100644 --- a/docs/graphics.md +++ b/docs/graphics.md @@ -116,7 +116,7 @@ duration (a track is added if needed). | Tool | On the Program monitor | |---|---| | Type (T) | Click empty picture: new point-text layer with a caret. Drag on empty picture: new paragraph-text layer with that box. Click a text layer: caret there. The box of the text being typed into is red. Type; ←/→ (⌥ word, ⌘ line), ↑/↓, Home/End, Shift to select, ⌘A, ⌘C/⌘X/⌘V, Return = new line, Backspace/Delete, Esc = stop editing. Drag inside the edited text to select. | -| Selection (V) | Click a layer to select it (box with handles and anchor point; only selected layers have a box, and a layer whose visibility is off has none and cannot be clicked); drag to move; drag the anchor point to move it alone; drag a handle to scale point text about its anchor, resize a paragraph-text box, or stretch a shape away from its opposite side (see *Point text and paragraph text*); double-click a text layer to edit it. | +| Selection (V) | Click a layer to select it (box with handles and anchor point; only selected layers have a box, and a layer whose visibility is off has none and cannot be clicked); drag to move; drag the anchor point to move it alone; drag a handle to scale point text about its anchor, resize a paragraph-text box (see *Point text and paragraph text*), or change a shape's Size: the dragged side or corner follows the pointer while the opposite one and the anchor point stay, Scale is untouched, and Shift on a corner keeps the proportions (a path, which has no Size, stretches instead); double-click a text layer to edit it. | | Rectangle / Ellipse | Drag to draw a shape layer. | | Pen (P) | Click to place points; click the first point (or Return) to close the path; Esc cancels. | From ce6afffc6f0e123107cffcd13aef6c39e080f0bd Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 20:39:48 +0200 Subject: [PATCH 089/144] #712: keyboard Slide keeps linked picture and sound aligned at a neighbour limit Problem: timeline.slideLeft/Right(5) slid every expanded selection member by the raw requested delta, so when a neighbour limited only one linked partner (e.g. a short sound-only follower), picture and sound ended up at different starts. timeline.slide already clamped to a common delta. Fix: add edit::slide_items, which clamps the delta by the most restrictive item and slides all items by it, and use it from both the keyboard Slide commands and timeline.slide. Co-Authored-By: Claude Opus 5.5 --- crates/edit/src/lib.rs | 21 +++++++++++++++++++++ crates/engine/src/commands.rs | 16 ++-------------- crates/engine/src/keyboard.rs | 8 ++------ crates/engine/src/keyboard_tests.rs | 26 ++++++++++++++++++++++++++ 4 files changed, 51 insertions(+), 20 deletions(-) diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 2f5a8382..554e9e5c 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -876,6 +876,27 @@ pub fn slide(seq: &mut Sequence, clip: ClipId, delta: Tick, ctx: &mut EditCtx) - Ok(d) } +/// Slide several items (a linked picture/sound pair, a selection) by one common delta: the most +/// restrictive item's neighbour limit clamps them all, so their relative timing is kept. +/// Returns the delta applied. +pub fn slide_items(seq: &mut Sequence, clips: &[ClipId], delta: Tick, ctx: &mut EditCtx) -> Result { + let mut d = delta; + for c in clips { + let x = slide(&mut seq.clone(), *c, d, ctx)?; + if x.0.signum() != d.0.signum() { + d = Tick::ZERO; + } else if x.abs() < d.abs() { + d = x; + } + } + if d != Tick::ZERO { + for c in clips { + slide(seq, *c, d, ctx)?; + } + } + Ok(d) +} + /// Rate stretch: change an item's duration by dragging an edge, adjusting speed to keep the same media. pub fn rate_stretch(seq: &mut Sequence, clip: ClipId, edge: Edge, delta: Tick, ctx: &mut EditCtx) -> Result { let (tid, it) = seq.find_item(clip).ok_or(EditError::NoItem(clip))?; diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 0b3a9f8a..47cc2f18 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -2349,20 +2349,8 @@ fn build() -> Vec { .or_else(|| p.get("deltaFrames").and_then(Value::as_i64).map(|f| s.sequence_rate().tick_of(f))) .unwrap_or_default(); let clips = with_links(s, &[c]); - s.edit_sequence("Slide", |q, ctx, _| { - // clamp across all linked partners, then slide them by the common delta - let mut dd = d; - for c in &clips { - let x = edit::slide(&mut q.clone(), *c, dd, ctx)?; - if x.abs() < dd.abs() { - dd = x; - } - } - for c in &clips { - edit::slide(q, *c, dd, ctx)?; - } - Ok(()) - })?; + // clamp across all linked partners, then slide them by the common delta + s.edit_sequence("Slide", |q, ctx, _| Ok(edit::slide_items(q, &clips, d, ctx)?))?; Ok(Value::Null) }), cmd!("timeline.rateStretch", "Rate Stretch", [], None, r#"{"clip":id,"edge":"in|out","delta":ticks}"#, has_seq, |s, p| { diff --git a/crates/engine/src/keyboard.rs b/crates/engine/src/keyboard.rs index d8487251..55563816 100644 --- a/crates/engine/src/keyboard.rs +++ b/crates/engine/src/keyboard.rs @@ -265,12 +265,8 @@ fn slip_selection(s: &mut Session, frames: i64) -> Result { fn slide_selection(s: &mut Session, frames: i64) -> Result { let d = s.sequence_rate().tick_of(frames); let clips = with_links(s, &s.state.selection.clone()); - s.edit_sequence("Slide", |q, ctx, _| { - for c in &clips { - edit::slide(q, *c, d, ctx)?; - } - Ok(()) - })?; + // one common delta, clamped by the most restrictive member, keeps linked partners in sync + s.edit_sequence("Slide", |q, ctx, _| Ok(edit::slide_items(q, &clips, d, ctx)?))?; Ok(Value::Null) } diff --git a/crates/engine/src/keyboard_tests.rs b/crates/engine/src/keyboard_tests.rs index b23a9809..60e9b7b4 100644 --- a/crates/engine/src/keyboard_tests.rs +++ b/crates/engine/src/keyboard_tests.rs @@ -376,3 +376,29 @@ fn reveal_nested_sequence_opens_it_at_the_matching_frame() { assert_ne!(s.state.active_sequence, Some(outer)); assert_eq!(s.playhead(), nest_clip.source_in + rate.tick_of(7), "{r}"); } + +#[test] +fn keyboard_slide_keeps_linked_partners_aligned_at_a_neighbour_limit() { + // #712: a three-frame sound-only follower limits only the sound partner + let mut s = Session::default(); + s.execute("file.newProject", json!({"name": "Slide"})).unwrap(); + s.execute("file.newSequence", json!({"name": "Sequence", "fps": 25, "video": 1, "audio": 1, "width": 16, "height": 16})).unwrap(); + let r = s.execute("file.newOfflineFile", json!({"name": "Media", "seconds": 4, "fps": 25, "video": true, "audio": true})).unwrap(); + let item = r["item"].as_u64().unwrap(); + let fr = s.sequence_rate().tick_of(1); + let r = + s.execute("timeline.place", json!({"item": item, "track": "V1", "audioTrack": "A1", "time": 0, "sourceIn": fr.0 * 20, "duration": fr.0 * 60})).unwrap(); + let (v, a) = (r["clips"][0].as_u64().unwrap(), r["clips"][1].as_u64().unwrap()); + let r = s.execute("timeline.place", json!({"item": item, "track": "A1", "time": fr.0 * 60, "sourceIn": 0, "duration": fr.0 * 3})).unwrap(); + let f = r["clips"][0].as_u64().unwrap(); + s.execute("sequence.linkedSelection", json!({"on": true})).unwrap(); + s.execute("timeline.select", json!({"clips": [v]})).unwrap(); + s.execute("timeline.slideRight5", json!({})).unwrap(); + assert_eq!(clip(&s, v).start, Tick(fr.0 * 2), "the pair moves only as far as the sound may"); + assert_eq!(clip(&s, a).start, clip(&s, v).start, "picture and sound stay aligned"); + let fl = clip(&s, f); + assert_eq!((fl.start, fl.duration, fl.source_in), (Tick(fr.0 * 62), fr, Tick(fr.0 * 2))); + // nothing more to give: a further slide right moves neither partner + s.execute("timeline.slideRight", json!({})).unwrap(); + assert_eq!((clip(&s, v).start, clip(&s, a).start), (Tick(fr.0 * 2), Tick(fr.0 * 2))); +} From 1edd8d6e334ebe7dbf7913df61cedc595fc24661 Mon Sep 17 00:00:00 2001 From: ReReddo <249398616+ReReddo@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:38:40 +0300 Subject: [PATCH 090/144] appearance: the Highlight Color drives the playhead, timecodes and focus (#707) Settings > Appearance > Highlight Color replaced only the accent (selections, primary buttons). The playhead, the timecodes, the focused panel's outline, drag values and the track-targeting buttons stayed blue, so a pink highlight left an interface that was mostly blue. A highlight of one's own now colours those too: each keeps the highlight's hue and is as much more or less saturated and bright as the theme's own blue is than the theme's highlight (the playhead and timecodes a little lighter, the targeting buttons a little deeper). With the default highlight nothing is derived, so every theme looks exactly as before. The track-targeting fill, written into the Timeline code, is a theme token now (track_target). Tests: theme (the default highlight leaves the five colours as designed in every theme; a pink one gives them its hue, lighter and darker as the default blues are). --- crates/ui-egui/src/panels/timeline.rs | 4 +-- crates/ui-egui/src/theme.rs | 50 +++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index d5997839..81e324f6 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -954,12 +954,12 @@ fn draw_transition(p: &egui::Painter, r: Rect, trn: &filmcraft_project::Transiti } } -/// A Premiere track-header button (patch / target): blue fill when on, full track height. +/// A Premiere track-header button (patch / target): highlight-blue fill when on, full track height. fn patch_button(ui: &mut egui::Ui, r: Rect, clip: Rect, label: &str, on: bool, show_off: bool, id: egui::Id, t: &Tokens) -> egui::Response { let resp = ui.interact(r.intersect(clip), id, Sense::click()); let p = ui.painter().with_clip_rect(clip); if on { - p.rect_filled(r, 2.0, Color32::from_rgb(0x26, 0x5b, 0xc1)); + p.rect_filled(r, 2.0, t.track_target); p.text(r.center(), Align2::CENTER_CENTER, label, Tokens::semibold(10.0), Color32::from_rgb(0xeb, 0xeb, 0xeb)); } else if show_off { if resp.hovered() { diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index b7560f32..a8683495 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -133,6 +133,8 @@ pub struct Tokens { pub tl_ruler_tick: Color32, pub tl_ruler_text: Color32, pub playhead: Color32, + /// Fill of a track header's source-patch / track-targeting button while it is on. + pub track_target: Color32, pub in_out_shade: Color32, pub clip_selected_border: Color32, pub render_red: Color32, @@ -225,6 +227,7 @@ impl Tokens { tl_ruler_tick: Color32::from_rgb(0x8d, 0x8d, 0x8d), tl_ruler_text: Color32::from_rgb(0xb0, 0xb0, 0xb0), playhead: Color32::from_rgb(0x58, 0x95, 0xec), + track_target: Color32::from_rgb(0x26, 0x5b, 0xc1), in_out_shade: Color32::from_rgb(0x3f, 0x3f, 0x3f), clip_selected_border: Color32::from_rgb(0xeb, 0xeb, 0xeb), render_red: Color32::from_rgb(0xe3, 0x48, 0x50), @@ -330,11 +333,28 @@ impl Tokens { } } - /// Settings ▸ Appearance on top of a theme: the highlight colour (selections, focus, primary - /// buttons) and accessible colour contrast (brighter secondary text and borders). + /// Settings ▸ Appearance on top of a theme: the highlight colour (selections, primary buttons, + /// the focus outline, the playhead, timecodes, drag values and track targeting) and accessible + /// colour contrast (brighter secondary text and borders). pub fn with_appearance(mut self, highlight: Option<[u8; 3]>, accessible_contrast: bool) -> Self { if let Some([r, g, b]) = highlight { let c = Color32::from_rgb(r, g, b); + // A highlight of the user's own also colours the theme's other blues: the same hue, as + // much more or less saturated and bright as each of them is than the theme's highlight. + // The theme's own highlight leaves them exactly as designed. + if c != self.accent { + let tint = |s: f32, v: f32| { + let mut h = egui::ecolor::HsvaGamma::from(c); + h.s = (h.s * s).clamp(0.0, 1.0); + h.v = (h.v * v).clamp(0.0, 1.0); + Color32::from(h) + }; + self.focus = tint(0.92, 1.06); + self.playhead = self.focus; + self.timecode = tint(0.98, 1.09); + self.hot_text = self.timecode; + self.track_target = tint(1.0, 0.865); + } let lift = |v: u8| v.saturating_add(16); self.accent = c; self.accent_hover = Color32::from_rgb(lift(r), lift(g), lift(b)); @@ -550,6 +570,32 @@ mod tests { } } + /// The colours the highlight drives besides the accent (#707). + fn highlight_blues(t: &Tokens) -> [Color32; 5] { + [t.focus, t.playhead, t.timecode, t.hot_text, t.track_target] + } + + #[test] + fn the_default_highlight_leaves_the_theme_blues_as_designed() { + let default = filmcraft_engine::settings::parse_hex(filmcraft_engine::settings::DEFAULT_HIGHLIGHT); + for k in [ThemeKind::Dark, ThemeKind::Medium, ThemeKind::Light] { + assert_eq!(highlight_blues(&Tokens::for_kind(k).with_appearance(default, false)), highlight_blues(&Tokens::for_kind(k)), "{k:?}"); + } + } + + #[test] + fn a_highlight_of_ones_own_colours_the_playhead_timecodes_focus_and_targeting() { + let pink = Color32::from_rgb(0xe0, 0x45, 0x7b); + let t = Tokens::for_kind(ThemeKind::Dark).with_appearance(Some([0xe0, 0x45, 0x7b]), false); + let hue = |c: Color32| egui::ecolor::HsvaGamma::from(c).h; + for (c, was) in highlight_blues(&t).into_iter().zip(highlight_blues(&Tokens::for_kind(ThemeKind::Dark))) { + assert_ne!(c, was); + assert!((hue(c) - hue(pink)).abs() < 0.01, "{c:?} has the highlight's hue"); + } + let v = |c: Color32| egui::ecolor::HsvaGamma::from(c).v; + assert!(v(t.playhead) > v(pink) && v(t.timecode) > v(pink) && v(t.track_target) < v(pink), "lighter and darker as the default blues are"); + } + /// Chinese media and track names must render even when the interface stays in English. /// Compare the rasterized glyphs with the missing-glyph box, including custom weight families. #[test] From c13f2aeddb7beaf72642de20a06abadf6748ca86 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 20:41:12 +0200 Subject: [PATCH 091/144] #695: Timeline markers can be picked and cleared Problem: Markers > Clear Selected Marker removes only a marker exactly at the playhead, but a click on a marker on the Timeline ruler left the playhead on the frame under the pointer (with snapping off), and right-clicking a marker opened the clip menu, so a marker placed on the Timeline could not be removed from there. Fix: a press on a marker's pentagon puts the playhead exactly on it, and right-clicking a marker opens a marker menu (Clear Selected Marker, Clear Markers; automation ids timeline.markerMenu.). The pentagon's rect is shared by drawing and hit-testing (timeline_hit::marker_rect). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/timeline.rs | 39 +++++++++++++++++++++-- crates/ui-egui/src/panels/timeline_hit.rs | 35 +++++++++++++++++++- crates/ui-egui/tests/sequence_ui.rs | 26 +++++++++++++++ 3 files changed, 97 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index d5997839..7fc8f8b5 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -22,7 +22,7 @@ use crate::icons::{self, Icon}; use crate::state::{TimelineView, Tool}; use crate::theme::Tokens; -use super::timeline_hit::{EdgeKind, Grab, edge_geometry, grab_at}; +use super::timeline_hit::{EdgeKind, Grab, edge_geometry, grab_at, marker_at, marker_rect}; pub use super::timeline_hit::{Hit, ROLL_PX, hit, selection_trim_kind}; const TOP_H: f32 = 58.0; // timecode + toolbar (left) / ruler (right) @@ -1304,7 +1304,7 @@ fn draw_top(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq: &Sequenc clip.rect_filled(Rect::from_min_max(pos2(x, marker_y), pos2(layout.x_of(m.start + m.duration), marker_y + 8.0)), 0.0, c.gamma_multiply(0.6)); } clip.add(egui::Shape::convex_polygon(shape, c, Stroke::NONE)); - let mr = Rect::from_center_size(pos2(x, marker_y + 6.0), vec2(10.0, 13.0)); + let mr = marker_rect(layout, m.start); app.auto.add(&format!("timeline.marker.{}", m.id.0), mr, &m.name); if ui.rect_contains_pointer(mr) && !m.name.is_empty() { egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::new(("mk", m.id.0)), egui::PopupAnchor::Pointer).show(|ui| { @@ -1578,6 +1578,22 @@ fn shift_track(seq: &Sequence, tid: TrackId, delta: i32) -> Option { /// The clip context menu: groups (separated by rules) of (label, command id). Entries marked `…` /// open their dialog through `menus::invoke`, like the same item in the Clip menu. +/// The menu of a right-clicked Timeline marker (automation ids `timeline.markerMenu.`); +/// the marker is under the playhead, where Clear Selected Marker looks for it (#695). +fn marker_menu(app: &mut FilmcraftApp, ctx: &egui::Context, ui: &mut egui::Ui) { + for (label, cmd) in [("Clear Selected Marker", "markers.clearCurrent"), ("Clear Markers", "markers.clearAll")] { + let label = crate::i18n::t(label); + let r = ui.button(label); + app.auto.add(&format!("timeline.markerMenu.{cmd}"), r.rect, label); + if r.clicked() { + if let Err(e) = crate::menus::invoke(app, ctx, cmd, json!({})) { + app.ui.status = e; + } + ui.close(); + } + } +} + pub(crate) const CLIP_MENU: &[&[(&str, &str)]] = &[ &[ ("Cut", "edit.cut"), @@ -2015,6 +2031,10 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & if mods.shift || app.session.prefs.timeline.snap_playhead { tt = snap_playhead(app, seq, layout, tt); } + // a press on a marker selects it: the playhead goes exactly onto it (#695) + if let Some(m) = marker_at(seq, layout, p) { + tt = m.max(Tick::ZERO); + } app.session.set_playhead(tt); } if resp.drag_started() { @@ -2199,6 +2219,17 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & let edit_point_menu_id = egui::Id::new("timeline.editPointMenu.target"); // the clip right-clicked, remembered while its menu is open: Unlink leaves only it selected let menu_clip_id = egui::Id::new("timeline.clipMenu.clip"); + // on a marker of the ruler, the marker menu instead (right-clicking a marker selects it first) + let marker_menu_id = egui::Id::new("timeline.markerMenu.open"); + if resp.secondary_clicked() + && let Some(p) = resp.interact_pointer_pos() + { + let marker = marker_at(seq, layout, p); + if let Some(m) = marker { + app.session.set_playhead(m.max(Tick::ZERO)); + } + ctx.data_mut(|d| d.insert_temp(marker_menu_id, marker.is_some())); + } if resp.secondary_clicked() && let Some(p) = resp.interact_pointer_pos() && let Hit::Clip { clip, edge, track } = hit(seq, layout, p) @@ -2226,6 +2257,10 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & } resp.context_menu(|ui| { ui.set_min_width(220.0); + if ctx.data(|d| d.get_temp::(marker_menu_id)).unwrap_or(false) { + marker_menu(app, &ctx, ui); + 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; diff --git a/crates/ui-egui/src/panels/timeline_hit.rs b/crates/ui-egui/src/panels/timeline_hit.rs index 16dd5f71..9e311f50 100644 --- a/crates/ui-egui/src/panels/timeline_hit.rs +++ b/crates/ui-egui/src/panels/timeline_hit.rs @@ -5,9 +5,10 @@ use std::cmp::Ordering; -use egui::{Modifiers, Pos2}; +use egui::{Modifiers, Pos2, Rect, pos2, vec2}; use filmcraft_edit::Edge; use filmcraft_project::{ClipId, Sequence, Track, TrackId, TransitionId}; +use filmcraft_time::Tick; use super::timeline::Layout; use crate::state::Tool; @@ -68,6 +69,18 @@ pub enum Hit { None, } +/// Where the Timeline ruler draws (and hit-tests) the pentagon of a marker at `start`. +pub fn marker_rect(layout: &Layout, start: Tick) -> Rect { + Rect::from_center_size(pos2(layout.x_of(start), layout.ruler.min.y + 8.0), vec2(10.0, 13.0)) +} + +/// The start of the marker whose pentagon is under `pos` on the ruler (the topmost, i.e. last +/// drawn, when several overlap). A press there puts the playhead on the marker, so Markers ▸ Clear +/// Selected Marker removes it (#695). +pub fn marker_at(seq: &Sequence, layout: &Layout, pos: Pos2) -> Option { + seq.markers.iter().rev().map(|m| m.start).find(|t| marker_rect(layout, *t).contains(pos)) +} + pub fn hit(seq: &Sequence, layout: &Layout, pos: Pos2) -> Hit { if layout.ruler.contains(pos) { return Hit::Ruler; @@ -404,6 +417,26 @@ mod tests { } } + #[test] + fn a_press_on_a_marker_lands_on_its_start() { + let (mut seq, l, _) = fixture(clips()); + let off = Tick::from_seconds_f64(3.017); + seq.markers.push(filmcraft_project::Marker { + id: filmcraft_project::MarkerId(9), + start: off, + duration: Tick::ZERO, + name: String::new(), + comment: String::new(), + kind: filmcraft_project::MarkerKind::Comment, + color: Label::Green, + }); + // 3 px beside the pentagon's tip, where the nearest frame is not the marker's time + assert_eq!(marker_at(&seq, &l, pos2(304.7, 10.0)), Some(off)); + assert_eq!(marker_at(&seq, &l, pos2(310.0, 10.0)), None); + assert_eq!(marker_at(&seq, &l, pos2(301.7, 30.0)), None); + assert_eq!(marker_at(&seq, &l, pos2(f32::NAN, 10.0)), None); + } + use egui::Modifiers; use crate::state::Tool; diff --git a/crates/ui-egui/tests/sequence_ui.rs b/crates/ui-egui/tests/sequence_ui.rs index 0a16547f..460b7929 100644 --- a/crates/ui-egui/tests/sequence_ui.rs +++ b/crates/ui-egui/tests/sequence_ui.rs @@ -321,6 +321,32 @@ fn markers_panel_colour_filter() { assert_eq!(d.ids("markers.row.").len(), 2); } +/// #695: a marker on the Timeline could not be cleared: with snapping off a click on it left the +/// playhead on the frame under the pointer, not on the marker Clear Selected Marker looks for, and +/// right-clicking it opened the clip menu. +#[test] +fn a_clicked_timeline_marker_can_be_cleared() { + let mut d = Driver::demo(); + d.exec("markers.clearAll", json!({})); + let first = d.exec("markers.add", json!({"frame": 30}))["marker"].as_u64().unwrap(); + let second = d.exec("markers.add", json!({"frame": 90}))["marker"].as_u64().unwrap(); + d.exec("sequence.snap", json!({"on": false})); + d.exec("playhead.set", json!({"frame": 0})); + // zoomed out so that a few px beside a marker is another frame + let v = &mut d.app().ui.timeline; + (v.pps, v.target_pps, v.scroll, v.target_scroll) = (60.0, 60.0, 0.0, 0.0); + d.frames(4); + let markers = |d: &mut Driver| d.app().session.active_sequence().unwrap().markers.iter().map(|m| m.id.0).collect::>(); + d.ok("ui.click", json!({"id": format!("timeline.marker.{first}"), "fx": 0.95})); + d.frames(2); + d.exec("markers.clearCurrent", json!({})); + assert_eq!(markers(&mut d), vec![second], "the clicked marker is cleared"); + d.ok("ui.click", json!({"id": format!("timeline.marker.{second}"), "fx": 0.95, "button": "right"})); + d.frames(3); + d.click("timeline.markerMenu.markers.clearCurrent"); + assert!(markers(&mut d).is_empty(), "cleared from its context menu"); +} + #[test] fn shift_semicolon_goes_to_the_next_gap() { let mut d = Driver::demo(); From 57cacfefa8b82dce966dc3274fb77bba173c5431 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 20:48:37 +0200 Subject: [PATCH 092/144] #715: a Matroska remux that keeps its timestamps starts at its first picture Problem: MkvSource used the raw Segment timestamps as media time and the absolute Info/Duration endpoint as the clip length. A remux that keeps its source's first PTS (ffmpeg -copyts, 1.48 s in the report) repeated its opening picture until then and made a 3.48 s clip out of 2 s of media. Fix: after opening, every track moves back by the earliest start among the playable tracks (an audio track's start before CodecDelay, never below zero), so a delay between picture and sound survives. The duration is the Segment end minus that origin, never less than the main track's samples. Zero-origin files are unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/codecs/src/mkv.rs | 129 +++++++++++++++++++++++++++++++++------ 1 file changed, 112 insertions(+), 17 deletions(-) diff --git a/crates/codecs/src/mkv.rs b/crates/codecs/src/mkv.rs index cef204d9..f8902394 100644 --- a/crates/codecs/src/mkv.rs +++ b/crates/codecs/src/mkv.rs @@ -102,10 +102,9 @@ fn audio_starts(file: &MkvFile, bytes: &crate::Src, ti: usize, rate: i64) -> Vec fn opus_starts(file: &MkvFile, bytes: &crate::Src, ti: usize, head: &filmcraft_opus::OpusHead, rate: i64) -> Vec { let Some(t) = file.tracks.get(ti) else { return Vec::new() }; let (n, d) = tb(t); - let scale_ns = (n as i128 * 1_000_000_000 / d as i128).max(1); let delay_ns = t.codec_delay_ns as i128; - // The demuxer's rounding of CodecDelay to ticks (half away from zero), undone here. - let delay_ticks = ((delay_ns + scale_ns / 2) / scale_ns) as i64; + // The demuxer's rounding of CodecDelay to ticks, undone here. + let delay_ticks = codec_delay_ticks(t); let skip = if t.codec_delay_ns > 0 { (delay_ns * rate as i128 / 1_000_000_000) as i64 } else { head.pre_skip as i64 }; let at = |pts: i64| ((pts.saturating_add(delay_ticks) as i128 * n as i128 * rate as i128 / d as i128) as i64).saturating_sub(skip); let packets = t.samples.iter().enumerate().map(|(i, s)| crate::audio::PacketTime { @@ -116,6 +115,46 @@ fn opus_starts(file: &MkvFile, bytes: &crate::Src, ti: usize, head: &filmcraft_o crate::audio::contiguous_starts(packets, stamp_tolerance(t, rate)) } +/// `CodecDelay` in timestamp ticks, rounded as the demuxer rounds it (half away from zero) before +/// subtracting it from every timestamp of the track. +fn codec_delay_ticks(t: &filmcraft_matroska::Track) -> i64 { + let (n, d) = tb(t); + let scale_ns = (n as i128 * 1_000_000_000 / d as i128).max(1); + i64::try_from((t.codec_delay_ns as i128 + scale_ns / 2) / scale_ns).unwrap_or(i64::MAX) +} + +/// The track timestamp that media time zero stands for (#715): the earliest start among the +/// playable tracks. Block timestamps count from the start of the Segment, and a remux that keeps +/// its source's timestamps (`ffmpeg -copyts`, a cut from a recording) starts its first picture +/// seconds in; taken as media time, that repeated the opening picture until then. An audio track +/// starts where its first packet does before `CodecDelay` was taken off (the delay only hides +/// priming), and never before zero. +fn media_origin(file: &MkvFile, vtrack: Option, atracks: &[usize]) -> i64 { + let start = |i: usize| { + let t = file.tracks.get(i)?; + let first = t.samples.iter().map(|s| s.pts).min()?; + Some(if t.kind == TrackKind::Audio { first.saturating_add(codec_delay_ticks(t)).max(0) } else { first }) + }; + vtrack.into_iter().chain(atracks.iter().copied()).filter_map(start).min().unwrap_or(0).max(0) +} + +/// Move every track back by `origin` timestamp ticks, so the streams keep their relative timing +/// (a delay between picture and sound survives). +fn rebase(file: &mut MkvFile, origin: i64) { + if origin <= 0 { + return; + } + for s in file.tracks.iter_mut().flat_map(|t| t.samples.iter_mut()) { + s.pts = s.pts.saturating_sub(origin); + } +} + +/// End of the last sample of track `i`, in its timebase. +fn track_end(file: &MkvFile, i: usize) -> Option<(i64, &filmcraft_matroska::Track)> { + let t = file.tracks.get(i)?; + Some((t.samples.iter().map(|s| s.pts.saturating_add(i64::try_from(s.duration).unwrap_or(i64::MAX))).max().unwrap_or(0), t)) +} + /// Two timestamp ticks in sample frames at `rate`, plus one: how far a packet's Matroska timestamp /// can be from its exact start. fn stamp_tolerance(t: &filmcraft_matroska::Track, rate: i64) -> i64 { @@ -144,7 +183,7 @@ fn tb(t: &filmcraft_matroska::Track) -> (i64, i64) { fn to_tick(t: &filmcraft_matroska::Track, pts: i64) -> Tick { let (n, d) = tb(t); - Tick::from_rational(pts * n, 1, d) + Tick::from_rational(pts, n, d) } /// A time as the track timestamp to look a frame up by: rounded to the nearest tick, as muxers @@ -273,7 +312,7 @@ impl MkvSource { /// Open from a random-access reader: only the index is read now, samples on demand. pub fn open_reader(name: &str, reader: filmcraft_media::SharedReader) -> crate::Result { let bytes = crate::Src(reader); - let file = filmcraft_matroska::open(&bytes).map_err(|e| CodecError::Container(e.to_string()))?; + let mut file = filmcraft_matroska::open(&bytes).map_err(|e| CodecError::Container(e.to_string()))?; let vtrack = file.tracks.iter().position(|t| t.kind == TrackKind::Video && !t.samples.is_empty()); // every audio track is a stream (the track count comes from the file: capped) let atracks: Vec = file @@ -287,6 +326,24 @@ impl MkvSource { if vtrack.is_none() && atracks.is_empty() { return Err(CodecError::Unsupported("no playable tracks".into())); } + let main = vtrack.or(atracks.first().copied()); + // The Segment's end (`Info/Duration` or the last sample), in Segment time. + let segment_end = match file.duration_ns() { + Some(ns) => Tick::from_rational(i64::try_from(ns).unwrap_or(i64::MAX), 1, 1_000_000_000), + None => main.and_then(|i| track_end(&file, i)).map(|(end, t)| to_tick(t, end)).unwrap_or_default(), + }; + // Media time zero is the first picture (or sound): the streams start at their common origin. + let origin = media_origin(&file, vtrack, &atracks); + rebase(&mut file, origin); + // `Info/Duration` is the Segment's end: the media lasts from the origin to there (never less + // than its main track's samples). + let duration = match main.and_then(|i| track_end(&file, i)) { + Some((end, t)) if origin > 0 => { + let (n, d) = tb(t); + (segment_end - Tick::from_rational(origin, n, d)).max(to_tick(t, end)) + } + _ => segment_end, + }; let mut explicit_color = None; let mut ventry = None; // display rotation from the track's Projection (portrait phone video is stored landscape) @@ -310,7 +367,7 @@ impl MkvSource { if explicit { explicit_color = Some(color); } - let secs = file.duration_ns().unwrap_or(0) as f64 / 1e9; + let secs = duration.seconds(); let bitrate = (secs > 0.0).then(|| (t.samples.iter().map(|s| s.size as u64).sum::() as f64 * 8.0 / secs) as u64); let ((w, h), par) = if rotation % 2 == 1 { ((h, w), (v.pixel_aspect().1, v.pixel_aspect().0)) } else { ((w, h), v.pixel_aspect()) }; VideoStreamInfo { @@ -340,17 +397,6 @@ impl MkvSource { AudioStreamInfo { sample_rate: rate, channels, codec: codec_label(&t.codec), bits_per_sample: a.bit_depth.map(|b| b as u32) } }) .collect(); - let duration = match file.duration_ns() { - Some(ns) => Tick::from_rational(ns as i64, 1, 1_000_000_000), - None => vtrack - .or(atracks.first().copied()) - .map(|i| { - let t = &file.tracks[i]; - let end = t.samples.iter().map(|s| s.pts + s.duration as i64).max().unwrap_or(0); - to_tick(t, end) - }) - .unwrap_or_default(), - }; let info = MediaInfo { name: name.to_string(), kind: if video.is_some() { MediaKind::Movie } else { MediaKind::AudioOnly }, @@ -601,3 +647,52 @@ mod multi_audio_tests { assert!(source.audio_stream(2, 0, 50, 48_000).is_err()); } } + +#[cfg(test)] +mod origin_tests { + use filmcraft_frame::PixelData; + use filmcraft_matroska::{MkvWriter, MuxOptions, TrackKind, TrackSpec}; + use filmcraft_media::{FrameRequest, MediaSource}; + use filmcraft_time::Tick; + + /// A flat grey Motion-JPEG picture: its level says which frame it is. + fn picture(k: u8) -> Vec { + let img = image::GrayImage::from_pixel(16, 16, image::Luma([20 + 20 * k])); + let mut out = Vec::new(); + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 95).encode_image(&img).unwrap(); + out + } + + /// #715: a remux that keeps its source's timestamps (first picture at 1.48 s, sound 0.2 s after + /// it) starts at its first picture, lasts as long as its pictures, and keeps the sound's delay. + /// It repeated picture 0 until 1.48 s, and the clip ran to the Segment's absolute end. + #[test] + fn timestamp_preserving_remux_starts_at_the_first_picture() { + 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((48_000.0, 1, Some(16))); + let mut w = MkvWriter::new(std::io::Cursor::new(Vec::new()), vec![v, a], MuxOptions::default()).unwrap(); + for k in 0..10u8 { + let pts = 1_480_000_000 + i64::from(k) * 40_000_000; + w.write_frame(0, pts, true, &picture(k), None).unwrap(); + if k == 5 { + let pcm: Vec = (0..960).flat_map(|_| 16_384i16.to_le_bytes()).collect(); + w.write_frame(1, 1_680_000_000, true, &pcm, Some(20_000_000)).unwrap(); + } + } + let src = super::MkvSource::open("shifted.mkv", w.finish().unwrap().into_inner().into()).unwrap(); + assert_eq!(src.info().duration, Tick::from_rational(10, 1, 25), "ten pictures at 25 fps"); + let level = |k: i64| { + let f = src.video_frame(FrameRequest::full(Tick::from_rational(k, 1, 25))).unwrap(); + let PixelData::Rgba8(px) = &f.data else { panic!("MJPEG decodes to RGBA8") }; + px[0] + }; + let seen: Vec = (0..10).map(level).collect(); + assert!(seen.windows(2).all(|p| p[0] < p[1]), "one picture per frame, in order: {seen:?}"); + let sound = src.audio(0, 48_000 * 2 / 5, 48_000).unwrap(); + assert!(sound.channels[0][9_590].abs() < 0.001, "silent before the delay"); + assert!((sound.channels[0][9_610] - 0.5).abs() < 0.001, "sound 0.2 s after the first picture"); + } +} From dfbcadb88c9e25c041c6d80cea2f278f407202c0 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 20:53:19 +0200 Subject: [PATCH 093/144] #719: positive Roundness rounds the Lumetri vignette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: the Lumetri vignette scaled its horizontal coordinate by `1 + vround * 0.0` and applied aspect correction only for negative Roundness, so 0 … +100 rendered identically (CPU path, the GPU op and the GPU shader parameters alike). Negative values moved towards a circle, the direction positive values should go. Fix: one helper, `gpufx::lumetri_vignette_aspect`, scales the horizontal coordinate by aspect^roundness: 0 keeps the ellipse that follows the frame, +100 is a circle, negative values widen the oval. Non-finite or out-of-range values fall back safely. The CPU path, the CPU FxOp and the GPU parameter packing all use it, so they stay in parity. Co-Authored-By: Claude Opus 5.5 --- crates/gpu/src/fx.rs | 2 +- crates/render/src/effects.rs | 55 +++++++++++++++++++++++++++++++++++- crates/render/src/gpufx.rs | 13 ++++++++- 3 files changed, 67 insertions(+), 3 deletions(-) diff --git a/crates/gpu/src/fx.rs b/crates/gpu/src/fx.rs index 0247105f..959a0fa0 100644 --- a/crates/gpu/src/fx.rs +++ b/crates/gpu/src/fx.rs @@ -213,7 +213,7 @@ fn steps(op: &FxOp) -> Vec { let w0 = 1.0 - wh * 0.15; let sh_k = sh * 0.35; let hl_k = hl * 0.35; - let vround_aspect = if *vround < 0.0 { aspect.powf(-vround) } else { 1.0 }; + let vround_aspect = filmcraft_render::gpufx::lumetri_vignette_aspect(*aspect, *vround); let st_k = [(st[0] - 0.5) * 0.3, (st[1] - 0.5) * 0.3, (st[2] - 0.5) * 0.3]; let ht_k = [(ht[0] - 0.5) * 0.3, (ht[1] - 0.5) * 0.3, (ht[2] - 0.5) * 0.3]; diff --git a/crates/render/src/effects.rs b/crates/render/src/effects.rs index a20e9475..f4eeb3ba 100644 --- a/crates/render/src/effects.rs +++ b/crates/render/src/effects.rs @@ -679,7 +679,7 @@ fn lumetri(img: &mut Image, e: &EffectInstance, cx: &FxCtx) { v = v.map(|q| l3 + (q - l3) * s); // vignette if va.abs() > 1e-4 { - let nx = (x as f32 / w - 0.5) * 2.0 * (1.0 + vround * 0.0) * if vround < 0.0 { aspect.powf(-vround) } else { 1.0 }; + let nx = (x as f32 / w - 0.5) * 2.0 * crate::gpufx::lumetri_vignette_aspect(aspect, vround); let ny = (y as f32 / h - 0.5) * 2.0; let d = (nx * nx + ny * ny).sqrt() / std::f32::consts::SQRT_2; let edge = ((d - vmid * 0.9) / (vfeather.max(0.01) * 0.9)).clamp(0.0, 1.0); @@ -1515,4 +1515,57 @@ mod lumetri_cpu_parity_tests { eprintln!("{name}: max sample diff vs fn lumetri: {max_diff:.2e}"); } } + + /// #719: positive Roundness rounds the Lumetri vignette (it used to be ignored), +100 + /// is a circle and negative values widen the oval, on the CPU path and the GPU op alike. + #[test] + fn lumetri_vignette_roundness_shapes_the_vignette() { + let (w, h) = (80usize, 40usize); + let cx = FxCtx { + t: Tick::ZERO, + px_scale: 1.0, + seconds: 0.0, + timecode: "", + clip_name: "", + project: None, + env: None, + working: filmcraft_color::WorkingSpace::Rec709, + }; + let render = |roundness: f64| { + let e = effect(&[ + ("basic_on", ParamValue::Bool(false)), + ("creative_on", ParamValue::Bool(false)), + ("vignette_amount", fl(-3.0)), + ("vignette_roundness", fl(roundness)), + ]); + let grey = Image { w, h, px: [0.5, 0.5, 0.5, 1.0].repeat(w * h) }; + let mut cpu = grey.clone(); + lumetri(&mut cpu, &e, &cx); + let mut op_img = grey; + let op = FxOp::eval(&e, &cx, w, h).expect("lumetri op"); + op.apply(&mut op_img); + for (a, b) in cpu.px.iter().zip(&op_img.px) { + assert!((a - b).abs() <= 1e-6, "roundness {roundness}: CPU and GPU op disagree"); + } + cpu + }; + let red = |img: &Image, x: usize, y: usize| img.px[(y * w + x) * 4]; + // 16 px right of and 16 px below the centre (40, 20) + let (right, below) = ((56, 20), (40, 36)); + + let ellipse = render(0.0); + assert!(red(&ellipse, right.0, right.1) > red(&ellipse, below.0, below.1) + 0.01, "0 follows the frame"); + + let circle = render(100.0); + assert_ne!(circle.px, ellipse.px, "positive roundness changes the vignette"); + let (r, b) = (red(&circle, right.0, right.1), red(&circle, below.0, below.1)); + assert!((r - b).abs() < 1e-4, "+100 is a circle: {r} vs {b}"); + assert!(r < red(&ellipse, right.0, right.1), "+100 darkens the sides more than 0"); + let half = render(50.0); + assert!(red(&half, 70, 20) < red(&ellipse, 70, 20), "+50 darkens the sides more than 0"); + assert!(red(&half, 70, 20) > red(&circle, 70, 20), "+50 darkens the sides less than +100"); + + let oval = render(-100.0); + assert!(red(&oval, 79, 20) > red(&ellipse, 79, 20), "negative values widen the oval"); + } } diff --git a/crates/render/src/gpufx.rs b/crates/render/src/gpufx.rs index 0b432ea0..572d9627 100644 --- a/crates/render/src/gpufx.rs +++ b/crates/render/src/gpufx.rs @@ -981,7 +981,7 @@ impl FxOp { v = v.map(|q| l3 + (q - l3) * s); // vignette if vignette_on && va.abs() > 1e-4 { - let nx = (x as f32 / w - 0.5) * 2.0 * (1.0 + vround * 0.0) * if vround < 0.0 { aspect.powf(-vround) } else { 1.0 }; + let nx = (x as f32 / w - 0.5) * 2.0 * lumetri_vignette_aspect(aspect, vround); let ny = (y as f32 / h - 0.5) * 2.0; let d = (nx * nx + ny * ny).sqrt() / std::f32::consts::SQRT_2; let edge = ((d - vmid * 0.9) / (vfeather.max(0.01) * 0.9)).clamp(0.0, 1.0); @@ -996,6 +996,17 @@ impl FxOp { } } +/// Horizontal scale of the Lumetri vignette's coordinates for a Roundness of `vround` +/// (−1 … 1) on a frame `aspect` wide. At 0 the vignette is an ellipse that follows the +/// frame; positive values move it towards a circle (1 = a true circle), negative values +/// stretch it into a wider oval. Non-finite or out-of-range input never yields NaN. +pub fn lumetri_vignette_aspect(aspect: f32, vround: f32) -> f32 { + if !aspect.is_finite() || aspect <= 0.0 || !vround.is_finite() { + return 1.0; + } + aspect.powf(vround.clamp(-1.0, 1.0)) +} + /// sRGB encoding that keeps values above 1 (super-whites) instead of clamping them. #[inline] fn linear_to_enc(v: f32) -> f32 { From ddb3890e689710dec8be710497cd9a64cf040f59 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 20:59:02 +0200 Subject: [PATCH 094/144] #456: a new bin asks for its name Problem: every New Bin entry (Cmd+B, File > New > Bin, the Project panel menu, the footer button and the context menus) ran `file.newBin` with the fixed name "New Bin", so each bin had to be renamed by hand afterwards. Fix: `file.newBin` without a `name` from the UI makes the bin in the shown bin (or the given parent), selects it and opens its inline rename field; agents that pass `name` still name it directly. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/menus.rs | 9 +++++++ crates/ui-egui/src/panels/project.rs | 30 ++++++++++++++++++++-- crates/ui-egui/src/panels/project_views.rs | 4 +-- crates/ui-egui/tests/project_panel_ui.rs | 20 +++++++++++++++ 4 files changed, 59 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9dff12d3..ba8f1f18 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -482,6 +482,15 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: crate::panels::color_dialogs::open_sequence(app); return Ok(json!({"dialog": "sequenceColor"})); } + // New Bin from menus, shortcuts and the Project panel asks for the name (inline rename); + // agents pass `name` to name the bin directly + "file.newBin" if params.get("name").is_none() => { + let r = crate::panels::project::new_bin(app, ¶ms); + if let Err(e) = &r { + app.ui.status = e.clone(); + } + return r; + } // From menus/shortcuts (no params) these ask first; agents pass params to act directly. "file.revert" if params.as_object().is_none_or(|m| m.is_empty()) && app.session.is_dirty() => { if app.session.path.is_none() { diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 09b04069..2a18137a 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -607,7 +607,7 @@ fn footer(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, v: &View, action "find" => actions.push(("edit.find".into(), json!({}))), "file.newBin" => { let parent = (v.bin != app.session.project.root.id).then_some(v.bin.0); - actions.push(("file.newBin".into(), json!({"name": "New Bin", "parent": parent}))); + actions.push(("file.newBin".into(), json!({"parent": parent, "panel": pre}))); } "project.delete" if app.session.state.project_selection.is_empty() => app.ui.status = tl!("Select items to clear").into(), _ => actions.push((id.into(), json!({}))), @@ -700,7 +700,7 @@ pub fn panel_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui) -> bool { ui.separator(); if item(ui, "newBin", tl!("New Bin"), true, s_bin.as_deref()) { let parent = (v.bin != app.session.project.root.id).then_some(v.bin.0); - actions.push(("file.newBin".into(), json!({"name": "New Bin", "parent": parent}))); + actions.push(("file.newBin".into(), json!({"parent": parent}))); } if item(ui, "newBinFromSelection", tl!("New Bin From Selection"), !app.session.state.project_selection.is_empty(), Some("Shift+B")) { actions.push(("file.newBinFromSelection".into(), json!({}))); @@ -939,6 +939,32 @@ fn start_rename(app: &mut FilmcraftApp, params: &Value) -> Result Ok(json!({"item": item, "bin": bin})) } +/// New Bin from a menu, a shortcut or the panel's buttons (no `name`): make a "New Bin" in `parent` +/// (left out: the bin the Project panel shows; null: the project's root) and start renaming it, so +/// its name can be typed right away (#456). `panel` names the panel instance that edits the name. +/// Agents pass `name` to `file.newBin` to name the bin directly. +pub fn new_bin(app: &mut FilmcraftApp, params: &Value) -> Result { + let shown = view_of(app, shown_inst(app)); + let parent = match params.get("parent") { + None => (shown.bin != app.session.project.root.id).then_some(shown.bin.0), + Some(Value::Null) => None, + Some(p) => Some(p.as_u64().ok_or("`parent` must be a bin id")?), + }; + let r = app.session.execute("file.newBin", json!({"name": tl!("New Bin"), "parent": parent})).map_err(|e| e.to_string())?; + let bin = r.get("bin").and_then(Value::as_u64).ok_or("the new bin has no id")?; + let text = app.session.project.root.find_bin(BinId(bin)).map(|b| b.name.clone()).ok_or("no such bin")?; + // a bin made inside a collapsed bin of the list opens it, so the name field is visible + if let Some(p) = parent + && !app.ui.expanded_bins.contains(&p) + { + app.ui.expanded_bins.push(p); + } + let panel = params.get("panel").and_then(Value::as_str).map_or(shown.prefix, str::to_string); + app.ui.project_panel.selected_bin = Some(bin); + app.ui.project_panel.rename = Some(Rename { item: None, bin: Some(bin), text, panel }); + Ok(r) +} + /// Commit the inline rename (Enter / focus lost). pub fn commit_rename(app: &mut FilmcraftApp) { let Some(r) = app.ui.project_panel.rename.take() else { return }; diff --git a/crates/ui-egui/src/panels/project_views.rs b/crates/ui-egui/src/panels/project_views.rs index 7a54afae..af7911fa 100644 --- a/crates/ui-egui/src/panels/project_views.rs +++ b/crates/ui-egui/src/panels/project_views.rs @@ -311,7 +311,7 @@ fn bin_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &View, bin: u64, actio ui.close(); } if ui.button(tl!("New Bin")).clicked() { - actions.push(("file.newBin".into(), json!({"name": "New Bin", "parent": bin}))); + actions.push(("file.newBin".into(), json!({"parent": bin, "panel": v.prefix}))); ui.close(); } } @@ -517,7 +517,7 @@ fn background_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &View, bin: Opt (tl!("Import…"), "file.import"), ] { if ui.button(label).clicked() { - let p = if cmd == "file.newBin" { json!({"name": tl!("New Bin"), "parent": bin}) } else { json!({}) }; + let p = if cmd == "file.newBin" { json!({"parent": bin, "panel": v.prefix}) } else { json!({}) }; actions.push((cmd.into(), p)); ui.close(); } diff --git a/crates/ui-egui/tests/project_panel_ui.rs b/crates/ui-egui/tests/project_panel_ui.rs index bb504f32..1b978cf2 100644 --- a/crates/ui-egui/tests/project_panel_ui.rs +++ b/crates/ui-egui/tests/project_panel_ui.rs @@ -477,6 +477,26 @@ fn footer_buttons_are_wired() { assert_eq!(d.app().session.prefs.project_panel.view.icon_sort.column, "Name"); } +#[test] +fn new_bin_asks_for_its_name() { + // #456: New Bin opens the name field of the new bin instead of leaving it "New Bin" + let mut d = project_driver(); + d.exec("project.view.set", json!({"view": "list"})); + d.click("project.button.file.newBin"); + d.frames(2); + let bin = d.app().ui.project_panel.rename.as_ref().and_then(|r| r.bin).expect("the new bin's name is being edited"); + assert!(d.has("project.rename"), "the name field is shown"); + d.key("Cmd+A"); + d.ok("ui.type", json!({"text": "Interviews"})); + d.key("Enter"); + let name = d.app().session.project.root.find_bin(filmcraft_project::BinId(bin)).unwrap().name.clone(); + assert_eq!(name, "Interviews"); + // with a name (agents) the bin is named directly and nothing is edited + let r = d.ok("ui.menu.invoke", json!({"id": "file.newBin", "params": {"name": "B-roll"}})); + assert!(r["bin"].as_u64().is_some(), "{r}"); + assert!(d.app().ui.project_panel.rename.is_none()); +} + // ------------------------------------------------------------------------------------ Media Browser fn media_dir(tag: &str) -> std::path::PathBuf { From 5d6f690e363ad1383a2db08d5388040c0c0cf8c6 Mon Sep 17 00:00:00 2001 From: ReReddo <249398616+ReReddo@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:05:23 +0300 Subject: [PATCH 095/144] effect controls: lines between property rows (#640) Effect Controls now draws a thin line under every row, as Premiere does, across the names, the values and the keyframe lane, so a row can be followed from its name to its value and keyframes. Each line runs through the middle of the spacing between rows, so a row sits centred between its two lines. It covers every kind of row: effect headers, properties, Custom Setup, the value and velocity graphs, the mask rows, and a selected transition's settings. The colour is the theme's separator, so it follows the dark and light themes. - effect_controls::row_line draws the line; effect_controls:: fit_dropdown_to_row keeps a dropdown (Blend Mode, a mask's mode) inside its row, clear of the lines. - The keyframe lane's ruler and clip bar are now painted after the rows, on their own background, so rows scrolled up under them no longer draw over them (their lines, and keyframe diamonds, which could already do so). --- crates/ui-egui/src/panels/effect_controls.rs | 32 ++++++++++++++++--- crates/ui-egui/src/panels/masks.rs | 4 +++ .../ui-egui/src/panels/transition_controls.rs | 9 ++++++ 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index e6161e36..9098c5a3 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -122,10 +122,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let lx = |tk: Tick| -> f32 { lane.min.x + (((tk - it.start).0 as f64 / dur) as f32).clamp(0.0, 1.0) * lane.width() }; let rate = seq.settings.frame_rate; let ruler = Rect::from_min_max(lane.min, pos2(lane.max.x, lane.min.y + RULER_H)); - paint_ruler(ui.painter(), ruler, &it, rate, seq.settings.drop_frame, &t); let bar = Rect::from_min_max(pos2(lane.min.x, ruler.max.y + 4.0), pos2(lane.max.x, ruler.max.y + 4.0 + CLIP_BAR_H)); - ui.painter().rect_filled(bar.shrink2(vec2(0.0, 2.0)), 2.0, t.clip_bar_bg); - ui.painter().with_clip_rect(bar).text(pos2(bar.min.x + 4.0, bar.center().y), Align2::LEFT_CENTER, &it.name, Tokens::ui(10.0), t.text); // click or drag anywhere on the ruler (or the bar) to move the playhead let scrub = Rect::from_min_max(ruler.min, bar.max); let sresp = ui.interact(scrub, egui::Id::new(("ec-scrub", clip.0)), Sense::click_and_drag()); @@ -162,6 +159,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { if resp.hovered() { bui.painter().rect_filled(r, 0.0, t.hover); } + row_line(bui, r, &lane, &t); icons::paint( bui.painter(), Rect::from_center_size(pos2(r.min.x + 10.0, r.center().y), vec2(10.0, 10.0)), @@ -215,7 +213,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { continue; } if crate::panels::audio_fx_editor::has_editor(&e.effect) { - custom_setup_row(app, bui, body, clip, idx, &e.effect); + custom_setup_row(app, bui, body, &lane, clip, idx, &e.effect); } for pd in &def.params { param_row(app, bui, body, clip, idx, e, None, pd, mt_now, &mut actions, &lane, &lx, it); @@ -234,6 +232,12 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } }); let _ = scroll_out; + // the ruler and the clip's bar, painted after the rows so rows scrolled up under them (their + // lines and keyframes) stay hidden + ui.painter().rect_filled(Rect::from_min_max(lane.min, pos2(lane.max.x, bar.max.y)), 0.0, t.tl_bg); + paint_ruler(ui.painter(), ruler, &it, rate, seq.settings.drop_frame, &t); + ui.painter().rect_filled(bar.shrink2(vec2(0.0, 2.0)), 2.0, t.clip_bar_bg); + ui.painter().with_clip_rect(bar).text(pos2(bar.min.x + 4.0, bar.center().y), Align2::LEFT_CENTER, &it.name, Tokens::ui(10.0), t.text); // playhead: a handle on the ruler and a line down the lane, while it is on the clip if ph >= it.start && ph <= it.end() { let px = lx(ph); @@ -249,10 +253,25 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { run(app, ui.ctx(), actions); } +/// The thin line under a row, across the names, the values and the keyframe lane, so a row can be +/// followed across the panel, as in Premiere (#640). It runs through the middle of the spacing +/// between rows, so each row sits centred between its two lines. +pub(crate) fn row_line(ui: &egui::Ui, r: Rect, lane: &Rect, t: &Tokens) { + let y = r.max.y + ui.spacing().item_spacing.y / 2.0; + ui.painter().line_segment([pos2(r.min.x, y), pos2(lane.max.x, y)], Stroke::new(1.0, t.separator)); +} + +/// Make a dropdown in a row fit inside the row, clear of its row lines. +pub(crate) fn fit_dropdown_to_row(ui: &mut egui::Ui) { + ui.spacing_mut().interact_size.y = ROW_H - 4.0; + ui.spacing_mut().button_padding.y = 1.0; +} + /// Premiere's "Custom Setup ▸ Edit…" row: opens the effect's Clip Fx Editor window. -fn custom_setup_row(app: &mut FilmcraftApp, ui: &mut egui::Ui, body: Rect, clip: ClipId, idx: usize, effect: &str) { +fn custom_setup_row(app: &mut FilmcraftApp, ui: &mut egui::Ui, body: Rect, lane: &Rect, clip: ClipId, idx: usize, effect: &str) { let t = app.tokens; let (r, _) = ui.allocate_exact_size(vec2(body.width(), ROW_H), Sense::hover()); + row_line(ui, r, lane, &t); ui.painter().text(pos2(r.min.x + 42.0, r.center().y), Align2::LEFT_CENTER, tl!("Custom Setup"), Tokens::ui(12.0), t.text_dim); let br = Rect::from_min_size(pos2(r.min.x + 160.0, r.min.y + 2.0), vec2(60.0, ROW_H - 4.0)); let resp = ui.interact(br, egui::Id::new(("fx-custom-setup", clip.0, idx)), Sense::click()); @@ -436,6 +455,7 @@ pub(crate) fn param_row( v }; let (r, _) = ui.allocate_exact_size(vec2(body.width(), ROW_H), Sense::hover()); + row_line(ui, r, lane, &t); let mut x = r.min.x + 26.0; // twirl-down for the value/velocity graphs (animated scalar and point params) if param.is_animated() && graphable(¶m.value) { @@ -502,6 +522,7 @@ pub(crate) fn param_row( } (ParamKind::Choice(opts), ParamValue::Choice(c)) => { let mut sel = *c as usize; + fit_dropdown_to_row(&mut vui); egui::ComboBox::from_id_salt(id).selected_text(opts.get(sel).map_or("", |o| crate::i18n::t(o))).width(130.0).show_ui(&mut vui, |ui| { for (i, o) in opts.iter().enumerate() { if ui.selectable_value(&mut sel, i, crate::i18n::t(o)).changed() { @@ -952,6 +973,7 @@ pub(crate) fn graph_rows( let t = app.tokens; let (vr, _) = ui.allocate_exact_size(vec2(body.width(), 110.0), Sense::hover()); let (velr, _) = ui.allocate_exact_size(vec2(body.width(), 64.0), Sense::hover()); + row_line(ui, velr, lane, &t); let speed = it.speed.abs().max(1e-6); let dur = it.duration.0.max(1) as f64; let to_media = |f: f64| it.source_in + Tick((f * dur * speed) as i64); diff --git a/crates/ui-egui/src/panels/masks.rs b/crates/ui-egui/src/panels/masks.rs index 4ffba906..a74489bd 100644 --- a/crates/ui-egui/src/panels/masks.rs +++ b/crates/ui-egui/src/panels/masks.rs @@ -45,6 +45,7 @@ pub fn effect_rows( let t = app.tokens; // creation tools: ellipse, 4-point polygon, free-draw Bézier let (r, _) = ui.allocate_exact_size(vec2(body.width(), ROW_H), Sense::hover()); + crate::panels::effect_controls::row_line(ui, r, lane, &t); let pen_on = app.ui.mask_pen.as_ref().is_some_and(|p| p.clip == clip.0 && p.effect == idx); for (i, (icon, kind, tip)) in [ (Icon::Ellipse, "ellipse", tl!("Create ellipse mask")), @@ -85,6 +86,7 @@ pub fn effect_rows( } else if resp.hovered() { ui.painter().rect_filled(r, 0.0, t.hover); } + crate::panels::effect_controls::row_line(ui, r, lane, &t); let tw = Rect::from_center_size(pos2(r.min.x + 26.0, r.center().y), vec2(10.0, 10.0)); icons::paint(ui.painter(), tw, if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_dim); let twresp = ui.interact(tw.expand(3.0), egui::Id::new(("mask-twirl", clip.0, idx, k)), Sense::click()); @@ -106,6 +108,7 @@ pub fn effect_rows( let mr = Rect::from_min_size(pos2(r.max.x - 118.0, r.min.y + 2.0), vec2(96.0, ROW_H - 4.0)); let mut mui = ui.new_child(egui::UiBuilder::new().max_rect(mr).layout(egui::Layout::left_to_right(egui::Align::Center))); let mut mode = m.mode; + crate::panels::effect_controls::fit_dropdown_to_row(&mut mui); egui::ComboBox::from_id_salt(("mask-mode", clip.0, idx, k)).selected_text(crate::i18n::t(mode.label())).width(88.0).show_ui(&mut mui, |ui| { for md in MaskMode::ALL { if ui.selectable_value(&mut mode, md, crate::i18n::t(md.label())).changed() { @@ -148,6 +151,7 @@ pub fn effect_rows( } // Inverted let (r, _) = ui.allocate_exact_size(vec2(body.width(), ROW_H), Sense::hover()); + crate::panels::effect_controls::row_line(ui, r, lane, &t); ui.painter().text(pos2(r.min.x + 40.0, r.center().y), Align2::LEFT_CENTER, tl!("Inverted"), Tokens::ui(12.0), t.text); let cr = Rect::from_min_size(pos2(r.min.x + (r.width() * 0.5).max(150.0), r.min.y + 2.0), vec2(20.0, ROW_H - 4.0)); let mut cui = ui.new_child(egui::UiBuilder::new().max_rect(cr)); diff --git a/crates/ui-egui/src/panels/transition_controls.rs b/crates/ui-egui/src/panels/transition_controls.rs index 80a8a0d4..47decaf2 100644 --- a/crates/ui-egui/src/panels/transition_controls.rs +++ b/crates/ui-egui/src/panels/transition_controls.rs @@ -74,6 +74,11 @@ pub fn effect_controls(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, id let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(vec2(10.0, 6.0))).id_salt(("tr-ec", id.0))); child.label(RichText::new(format!("{seq_name} · {name}")).color(t.text_dim)); child.add_space(4.0); + // the line under each row, as for a clip's effects (#640): halfway into the row spacing + let row_line = |ui: &egui::Ui| { + let y = ui.cursor().top() - 4.0; + ui.painter().line_segment([egui::pos2(rect.min.x, y), egui::pos2(rect.max.x, y)], egui::Stroke::new(1.0, t.separator)); + }; egui::ScrollArea::vertical().id_salt(("tr-ec-scroll", id.0)).auto_shrink([false, false]).show(&mut child, |ui| { egui::Grid::new(("tr-ec-grid", id.0)).num_columns(2).spacing([14.0, 8.0]).show(ui, |ui| { ui.label(tl!("Duration")); @@ -84,6 +89,7 @@ pub fn effect_controls(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, id changes.push(json!({"frames": frames, "merge": true, "begin": r.drag_started() || !r.dragged()})); } ui.end_row(); + row_line(ui); ui.label(tl!("Alignment")); let shown = align.label(); @@ -106,6 +112,7 @@ pub fn effect_controls(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, id .inner; elems.push(("effectControls.transition.alignment".into(), r.rect, shown.to_string())); ui.end_row(); + row_line(ui); ui.label(tl!("Reverse")); let mut rev = x.reverse; @@ -115,6 +122,7 @@ pub fn effect_controls(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, id changes.push(json!({"reverse": rev})); } ui.end_row(); + row_line(ui); for pd in def.map(|d| d.params.as_slice()).unwrap_or(&[]) { let value = x.effect.params.get(pd.id).map_or(&pd.default, |p| &p.value); @@ -123,6 +131,7 @@ pub fn effect_controls(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, id changes.push(v); } ui.end_row(); + row_line(ui); } }); ui.add_space(8.0); From ade56c62770718c385727879e5ea6e5f22cd5052 Mon Sep 17 00:00:00 2001 From: ReReddo <249398616+ReReddo@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:01:55 +0300 Subject: [PATCH 096/144] effect controls: draggable divider, scroll the whole panel (#643) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The split between the effect list and the keyframe area was fixed. Now a divider between them drags left or right, as in Premiere: it shows a resize cursor and turns blue under the pointer, and the width it sets stays while FilmCraft runs (UiState::effect_controls_split, automation id effectControls.divider). The effect list keeps the 260 points its rows are laid out for (values from 150 points in, the reset button at the end), the keyframe area at least 60. Scrolling was tied to the effect list. The scroll area now spans the list and the keyframe area, so the mouse wheel scrolls anywhere over the panel and the scrollbar sits at its right edge. The ruler's click and drag are handled after the rows, which now reach under it, so they still move the playhead. At narrow widths labels no longer run into what follows them: property, effect and mask names are cut off with "…" (effect_controls::row_label), and a dropdown narrows to the space left in its row. Smaller readability fixes in the same panel: - Keyframes in the lane are drawn at about Premiere's size (a diamond ~9 points wide instead of ~7; Hold squares and Bezier circles to match), with a grab area to match. - The keyframe navigator's arrows are taller (11 points, clickable over 20 of the row's 22) and its diamond larger; Properties uses the same navigator, so it gets them too. - Checkboxes (Bypass, Uniform Scale, a mask's Inverted) sit centred between their row lines (effect_controls::fit_to_row, which also fits dropdowns). Tests: keyframes_ui effect_controls_divider_and_scrolling_anywhere (the divider drags, keeps its width and both minimum widths; the wheel scrolls the rows over the keyframe area). --- crates/ui-egui/src/panels/effect_controls.rs | 90 ++++++++++++++------ crates/ui-egui/src/panels/masks.rs | 15 +++- crates/ui-egui/src/state.rs | 5 ++ crates/ui-egui/tests/keyframes_ui.rs | 39 +++++++++ 4 files changed, 121 insertions(+), 28 deletions(-) diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 9098c5a3..fd6588db 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -13,6 +13,10 @@ use crate::state::KeyframeRef; use crate::theme::Tokens; const ROW_H: f32 = 22.0; +/// The narrowest the effect list and the keyframe area can be dragged (#643): the list's rows are +/// laid out for at least 260 points (values from 150 points in, the reset button at the end). +const MIN_LIST_W: f32 = 260.0; +const MIN_LANE_W: f32 = 60.0; /// Keyframe lane header: the time ruler (beside the Source / Sequence pills) and the clip's bar. const RULER_H: f32 = 24.0; const CLIP_BAR_H: f32 = 18.0; @@ -99,7 +103,13 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { crate::dock::placeholder(ui, rect, &t, tl!("(no sequences)")); return; }; - let split = rect.min.x + (rect.width() * 0.58).max(260.0).min(rect.width() - 60.0); + // the divider between the effect list and the keyframe area; dragging it sets the list's width + // (#643), each side keeping a minimum width + // (the keyframe area has 4 points of margin left of it and 6 right) + let max_list = (rect.width() - MIN_LANE_W - 10.0).max(MIN_LIST_W); + let list_w = if app.ui.effect_controls_split > 0.0 { app.ui.effect_controls_split } else { (rect.width() * 0.58).max(260.0) }; + let list_w = list_w.clamp(MIN_LIST_W, max_list); + let split = rect.min.x + list_w; let head = Rect::from_min_size(rect.min + vec2(8.0, 4.0), vec2(split - rect.min.x - 12.0, 24.0)); // Premiere: two pill tabs — "Source · clip" and "Sequence · clip" (active) let pill = |ui: &mut egui::Ui, r: Rect, text: &str, active: bool| { @@ -123,22 +133,14 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let rate = seq.settings.frame_rate; let ruler = Rect::from_min_max(lane.min, pos2(lane.max.x, lane.min.y + RULER_H)); let bar = Rect::from_min_max(pos2(lane.min.x, ruler.max.y + 4.0), pos2(lane.max.x, ruler.max.y + 4.0 + CLIP_BAR_H)); - // click or drag anywhere on the ruler (or the bar) to move the playhead let scrub = Rect::from_min_max(ruler.min, bar.max); - let sresp = ui.interact(scrub, egui::Id::new(("ec-scrub", clip.0)), Sense::click_and_drag()); app.auto.add("effectControls.lane", lane, "keyframe lane"); app.auto.add("effectControls.ruler", scrub, "time ruler"); - if (sresp.dragged() || sresp.clicked()) - && let Some(pos) = sresp.interact_pointer_pos() - { - let f = ((pos.x - lane.min.x) / lane.width()).clamp(0.0, 1.0) as f64; - let tk = rate.snap_nearest(it.start + Tick((f * it.duration.0 as f64) as i64)); - app.stop(); - app.session.set_playhead(tk); - } let body = Rect::from_min_max(pos2(rect.min.x, head.max.y + 4.0), pos2(split, rect.max.y - 26.0)); let mut actions: Vec<(String, Value)> = Vec::new(); - let mut bui = ui.new_child(egui::UiBuilder::new().max_rect(body).id_salt("ec-body")); + // the rows span the list and the keyframe area: the wheel scrolls anywhere over them and the + // scrollbar sits at the panel's right edge (#643) + let mut bui = ui.new_child(egui::UiBuilder::new().max_rect(Rect::from_min_max(body.min, pos2(lane.max.x, body.max.y))).id_salt("ec-body")); bui.set_clip_rect(Rect::from_min_max(body.min, pos2(rect.max.x, body.max.y))); let scroll_out = egui::ScrollArea::vertical().id_salt("ec-scroll").auto_shrink([false, false]).show(&mut bui, |bui| { for (clip, it, kind) in &clips { @@ -176,7 +178,8 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { if fxresp.clicked() { actions.push(("effects.toggleEnabled".into(), json!({"clip": clip.0, "index": idx}))); } - bui.painter().text(pos2(r.min.x + 42.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::t(def.name), Tokens::ui(12.0), t.text); + // (the reset button sits at the row's right end) + row_label(bui, pos2(r.min.x + 42.0, r.center().y), crate::i18n::t(def.name), r.max.x - 26.0 - (r.min.x + 42.0), Tokens::ui(12.0), t.text); // reset button let rr = Rect::from_center_size(pos2(r.max.x - 14.0, r.center().y), vec2(16.0, 16.0)); let rresp = bui.interact(rr, egui::Id::new(("fxreset", clip.0, idx)), Sense::click()).on_hover_text(tl!("Reset Effect")); @@ -238,6 +241,26 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { paint_ruler(ui.painter(), ruler, &it, rate, seq.settings.drop_frame, &t); ui.painter().rect_filled(bar.shrink2(vec2(0.0, 2.0)), 2.0, t.clip_bar_bg); ui.painter().with_clip_rect(bar).text(pos2(bar.min.x + 4.0, bar.center().y), Align2::LEFT_CENTER, &it.name, Tokens::ui(10.0), t.text); + // click or drag anywhere on the ruler (or the bar) to move the playhead (after the rows, which + // reach under it, so it gets the pointer first) + let sresp = ui.interact(scrub, egui::Id::new(("ec-scrub", clip.0)), Sense::click_and_drag()); + if (sresp.dragged() || sresp.clicked()) + && let Some(pos) = sresp.interact_pointer_pos() + { + let f = ((pos.x - lane.min.x) / lane.width()).clamp(0.0, 1.0) as f64; + let tk = rate.snap_nearest(it.start + Tick((f * it.duration.0 as f64) as i64)); + app.stop(); + app.session.set_playhead(tk); + } + // the divider: a line the height of the panel, a resize cursor and a wider grab area + let divider = Rect::from_min_max(pos2(split - 2.0, rect.min.y), pos2(split + 4.0, rect.max.y - 26.0)); + let dresp = ui.interact(divider, egui::Id::new("ec-divider"), Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal); + if dresp.dragged() { + app.ui.effect_controls_split = (list_w + dresp.drag_delta().x).clamp(MIN_LIST_W, max_list); + } + let dcol = if dresp.hovered() || dresp.dragged() { t.accent } else { t.separator }; + ui.painter().line_segment([pos2(split + 1.5, rect.min.y), pos2(split + 1.5, rect.max.y - 26.0)], Stroke::new(1.0, dcol)); + app.auto.add("effectControls.divider", divider, "divider"); // playhead: a handle on the ruler and a line down the lane, while it is on the clip if ph >= it.start && ph <= it.end() { let px = lx(ph); @@ -261,8 +284,20 @@ pub(crate) fn row_line(ui: &egui::Ui, r: Rect, lane: &Rect, t: &Tokens) { ui.painter().line_segment([pos2(r.min.x, y), pos2(lane.max.x, y)], Stroke::new(1.0, t.separator)); } -/// Make a dropdown in a row fit inside the row, clear of its row lines. -pub(crate) fn fit_dropdown_to_row(ui: &mut egui::Ui) { +/// A row's label, cut off with "…" where it would run into what follows it, as Premiere does when +/// the effect list is narrow (#643). +pub(crate) fn row_label(ui: &egui::Ui, left_center: Pos2, text: &str, max_w: f32, font: egui::FontId, color: Color32) { + let mut job = egui::text::LayoutJob::single_section(text.to_owned(), egui::TextFormat::simple(font, color)); + job.wrap.max_width = max_w.max(0.0); + job.wrap.max_rows = 1; + job.wrap.break_anywhere = true; + job.wrap.overflow_character = Some('…'); + let g = ui.fonts_mut(|f| f.layout_job(job)); + ui.painter().galley(pos2(left_center.x, left_center.y - g.size().y / 2.0), g, color); +} + +/// Make a dropdown or a checkbox in a row fit inside the row, centred between its row lines. +pub(crate) fn fit_to_row(ui: &mut egui::Ui) { ui.spacing_mut().interact_size.y = ROW_H - 4.0; ui.spacing_mut().button_padding.y = 1.0; } @@ -361,10 +396,11 @@ fn keyframe_nav( for (d, target, name, tip) in [(-1.0, param.prev_keyframe(mt), "prevKeyframe", "Go to previous keyframe"), (1.0, param.next_keyframe(mt), "nextKeyframe", "Go to next keyframe")] { - let r = Rect::from_center_size(at + vec2(d * NAV_STEP, 0.0), vec2(12.0, 14.0)); + // the arrows almost the row's height to click, as Premiere's + let r = Rect::from_center_size(at + vec2(d * NAV_STEP, 0.0), vec2(14.0, 20.0)); let resp = ui.interact(r, id.with(name), Sense::click()).on_hover_text(crate::i18n::t(tip)); let c = r.center(); - let pts = vec![c + vec2(-3.0 * d, -4.0), c + vec2(-3.0 * d, 4.0), c + vec2(3.0 * d, 0.0)]; + let pts = vec![c + vec2(-3.5 * d, -5.5), c + vec2(-3.5 * d, 5.5), c + vec2(3.5 * d, 0.0)]; ui.painter().add(egui::Shape::convex_polygon(pts, if target.is_some() { t.text } else { t.text_faint }, Stroke::NONE)); app.auto.add(&format!("{auto}.{name}"), r, tip); if resp.clicked() @@ -374,12 +410,12 @@ fn keyframe_nav( } } } - let r = Rect::from_center_size(at, vec2(13.0, 13.0)); + let r = Rect::from_center_size(at, vec2(15.0, 15.0)); let resp = ui.interact(r.expand(1.5), id.with("key"), Sense::click()).on_hover_text(crate::i18n::t(tip)); if param.is_some_and(|p| p.keyframes.iter().any(|k| k.time == mt)) { icons::paint(ui.painter(), r, Icon::Keyframe, t.hot_text); } else { - let s = 4.0; + let s = 4.7; let outline = vec![at + vec2(0.0, -s), at + vec2(s, 0.0), at + vec2(0.0, s), at + vec2(-s, 0.0)]; ui.painter().add(egui::Shape::closed_line(outline, Stroke::new(1.2, if resp.hovered() { t.text } else { t.text_dim }))); } @@ -483,8 +519,8 @@ pub(crate) fn param_row( app.auto.add(&format!("effectControls.{}.{}.stopwatch", e.effect, pkey), sw, "Toggle animation"); } x += 14.0; - ui.painter().text(pos2(x, r.center().y), Align2::LEFT_CENTER, crate::i18n::t(pd.label), Tokens::ui(12.0), t.text); let vx = r.min.x + (r.width() * 0.5).max(150.0); + row_label(ui, pos2(x, r.center().y), crate::i18n::t(pd.label), vx - 6.0 - x, Tokens::ui(12.0), t.text); let value = param.value_at(mt); let mut vui = ui.new_child( egui::UiBuilder::new() @@ -516,14 +552,17 @@ pub(crate) fn param_row( } (ParamKind::Bool, ParamValue::Bool(b)) => { let mut v = *b; + fit_to_row(&mut vui); if vui.checkbox(&mut v, "").changed() { set = Some(json!(v)); } } (ParamKind::Choice(opts), ParamValue::Choice(c)) => { let mut sel = *c as usize; - fit_dropdown_to_row(&mut vui); - egui::ComboBox::from_id_salt(id).selected_text(opts.get(sel).map_or("", |o| crate::i18n::t(o))).width(130.0).show_ui(&mut vui, |ui| { + fit_to_row(&mut vui); + // (narrower when the effect list is narrow, so it stays left of the divider) + let w = vui.available_width().min(130.0); + egui::ComboBox::from_id_salt(id).selected_text(opts.get(sel).map_or("", |o| crate::i18n::t(o))).width(w).show_ui(&mut vui, |ui| { for (i, o) in opts.iter().enumerate() { if ui.selectable_value(&mut sel, i, crate::i18n::t(o)).changed() { set = Some(json!(i)); @@ -594,7 +633,8 @@ pub(crate) fn param_row( let id = egui::Id::new(("kf", clip.0, idx, pkey, k.time.0)); let drag_off: Option = ui.data(|d| d.get_temp(id)); let kx = lane.min.x + f * lane.width() + drag_off.unwrap_or(0.0); - let kr = Rect::from_center_size(pos2(kx, y), vec2(11.0, 11.0)); + // about Premiere's size: a diamond ~9 points wide + let kr = Rect::from_center_size(pos2(kx, y), vec2(15.0, 15.0)); let resp = ui.interact(kr.expand(2.0), id, Sense::click_and_drag()); app.auto.add(&format!("effectControls.{}.{}.keyframe.{}", e.effect, pkey, k.time.0), kr, "keyframe"); // highlighted when selected (not merely under the playhead: that lit up every @@ -607,11 +647,11 @@ pub(crate) fn param_row( let col = if sel { t.hot_text } else { Color32::from_rgb(0xb0, 0xb0, 0xb0) }; match k.interp { filmcraft_project::Interpolation::Hold => { - ui.painter().rect_filled(Rect::from_center_size(kr.center(), vec2(8.0, 8.0)), 0.0, col); + ui.painter().rect_filled(Rect::from_center_size(kr.center(), vec2(9.0, 9.0)), 0.0, col); } filmcraft_project::Interpolation::Linear => icons::paint(ui.painter(), kr, Icon::Keyframe, col), _ => { - ui.painter().circle_filled(kr.center(), 4.5, col); + ui.painter().circle_filled(kr.center(), 5.5, col); } } if resp.dragged() { diff --git a/crates/ui-egui/src/panels/masks.rs b/crates/ui-egui/src/panels/masks.rs index a74489bd..5062918e 100644 --- a/crates/ui-egui/src/panels/masks.rs +++ b/crates/ui-egui/src/panels/masks.rs @@ -101,14 +101,22 @@ pub fn effect_rows( Icon::Pen }; icons::paint(ui.painter(), glyph, gi, if selected { t.accent } else { t.text_dim }); - ui.painter().text(pos2(r.min.x + 54.0, r.center().y), Align2::LEFT_CENTER, &m.name, Tokens::ui(12.0), t.text); + // (the mode dropdown starts 118 points from the row's right end) + crate::panels::effect_controls::row_label( + ui, + pos2(r.min.x + 54.0, r.center().y), + &m.name, + r.max.x - 122.0 - (r.min.x + 54.0), + Tokens::ui(12.0), + t.text, + ); let base = format!("effectControls.{}.mask{k}", e.effect); app.auto.add(&base, r, &m.name); // mode let mr = Rect::from_min_size(pos2(r.max.x - 118.0, r.min.y + 2.0), vec2(96.0, ROW_H - 4.0)); let mut mui = ui.new_child(egui::UiBuilder::new().max_rect(mr).layout(egui::Layout::left_to_right(egui::Align::Center))); let mut mode = m.mode; - crate::panels::effect_controls::fit_dropdown_to_row(&mut mui); + crate::panels::effect_controls::fit_to_row(&mut mui); egui::ComboBox::from_id_salt(("mask-mode", clip.0, idx, k)).selected_text(crate::i18n::t(mode.label())).width(88.0).show_ui(&mut mui, |ui| { for md in MaskMode::ALL { if ui.selectable_value(&mut mode, md, crate::i18n::t(md.label())).changed() { @@ -154,7 +162,8 @@ pub fn effect_rows( crate::panels::effect_controls::row_line(ui, r, lane, &t); ui.painter().text(pos2(r.min.x + 40.0, r.center().y), Align2::LEFT_CENTER, tl!("Inverted"), Tokens::ui(12.0), t.text); let cr = Rect::from_min_size(pos2(r.min.x + (r.width() * 0.5).max(150.0), r.min.y + 2.0), vec2(20.0, ROW_H - 4.0)); - let mut cui = ui.new_child(egui::UiBuilder::new().max_rect(cr)); + let mut cui = ui.new_child(egui::UiBuilder::new().max_rect(cr).layout(egui::Layout::left_to_right(egui::Align::Center))); + crate::panels::effect_controls::fit_to_row(&mut cui); let mut inv = m.inverted; if cui.checkbox(&mut inv, "").changed() { actions.push(("masks.set".into(), json!({"clip": clip.0, "effect": idx, "mask": k, "inverted": inv}))); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index b5775263..242fc6e4 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -442,6 +442,10 @@ pub struct UiState { pub lumetri_grid_folder: Option, /// Collapsed effect sections in Effect Controls ("clip:index"). pub collapsed_fx: Vec, + /// Width of Effect Controls' effect list, left of the keyframe area (points; 0 = the default + /// share of the panel). Dragging the divider sets it (#643). + #[serde(default)] + pub effect_controls_split: f32, pub show_menu_bar: bool, /// The header bar (Home, Import, Edit, Export, workspaces). An app that embeds FilmCraft can hide it. #[serde(default = "shown")] @@ -950,6 +954,7 @@ impl Default for UiState { expanded_fx: vec!["Video Transitions".into(), "Video Transitions/Dissolve".into()], lumetri_grid_folder: None, collapsed_fx: vec![], + effect_controls_split: 0.0, show_menu_bar: true, show_header: true, show_status_bar: true, diff --git a/crates/ui-egui/tests/keyframes_ui.rs b/crates/ui-egui/tests/keyframes_ui.rs index c97cd506..61164c7b 100644 --- a/crates/ui-egui/tests/keyframes_ui.rs +++ b/crates/ui-egui/tests/keyframes_ui.rs @@ -258,3 +258,42 @@ fn clicking_a_lane_keyframe_selects_only_that_keyframe() { d.click("effectControls.motion.position.addKeyframe"); assert_eq!(selection(&mut d), json!([]), "a deleted keyframe is not selected"); } + +/// Effect Controls' divider (#643): dragging it widens the effect list or the keyframe area, each +/// keeping a minimum width, and the width stays. The mouse wheel scrolls the rows over the +/// keyframe area too, not only over the effect list. +#[test] +fn effect_controls_divider_and_scrolling_anywhere() { + let (mut d, clip) = Driver::demo(); + let lane = d.rect("effectControls.lane"); + let div = d.rect("effectControls.divider"); + let (x, y) = (div[0] + div[2] / 2.0, div[1] + div[3] / 2.0); + d.ok("ui.drag", json!({"from": {"x": x, "y": y}, "to": {"x": x + 80.0, "y": y}, "steps": 6})); + let narrower = d.rect("effectControls.lane"); + assert!((narrower[0] - (lane[0] + 80.0)).abs() <= 1.0, "the keyframe area starts 80 points further right: {narrower:?} vs {lane:?}"); + assert!((narrower[0] + narrower[2] - (lane[0] + lane[2])).abs() <= 1.0, "and still ends at the panel's edge"); + d.frames(3); + assert_eq!(d.rect("effectControls.lane"), narrower, "the width stays"); + // dragged all the way right, the keyframe area keeps its minimum width + let drag_divider = |d: &mut Driver, by: f64| { + let div = d.rect("effectControls.divider"); + let (x, y) = (div[0] + div[2] / 2.0, div[1] + div[3] / 2.0); + d.ok("ui.drag", json!({"from": {"x": x, "y": y}, "to": {"x": x + by, "y": y}, "steps": 6})); + }; + drag_divider(&mut d, 3000.0); + assert!(d.rect("effectControls.lane")[2] >= 59.0, "the keyframe area can't vanish"); + // and all the way left, the effect list keeps the width its rows are laid out for + drag_divider(&mut d, -3000.0); + assert!(d.rect("effectControls.effect.motion")[2] >= 259.0, "the effect list stays wide enough for its values"); + + // enough effects to scroll; the wheel over the keyframe area scrolls the rows + for fx in ["gaussian_blur", "transform", "crop", "tint"] { + d.exec("effects.apply", json!({"clips": [clip.id], "effect": fx})); + } + let before = d.rect("effectControls.motion.scale.stopwatch"); + let lane = d.rect("effectControls.lane"); + d.ok("ui.scroll", json!({"x": lane[0] + lane[2] / 2.0, "y": lane[1] + lane[3] * 0.7, "dx": 0.0, "dy": -80.0})); + d.frames(4); + let after = d.find("effectControls.motion.scale.stopwatch"); + assert!(after.is_none_or(|r| r[1] < before[1] - 20.0), "the rows scrolled up: {before:?} → {after:?}"); +} From e214d84e84ca2b905368887b1e4ba63c04e219c3 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 15:05:04 -0400 Subject: [PATCH 097/144] Revert "Merge pull request #589 from Ni-zav/fix/547-japanese-font-priority-s20261010T084251Z-a6c2d9" This reverts commit b1f2973c9d7d70ce6ffea7c004b04ac345543306, reversing changes made to 8fa24de32b54517850088b68ce283ba714f49afc. --- crates/ui-egui/src/i18n/mod.rs | 54 ++++++---------------------------- 1 file changed, 9 insertions(+), 45 deletions(-) diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index d9e14c56..205fb5c2 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -250,34 +250,21 @@ pub fn craft_japanese_font() -> bool { .any(|f| f.info.origin == filmcraft_text::fonts::CRAFT_ORIGIN && JAPANESE_SAMPLE.chars().all(|c| f.has_char(c))) } -/// Put the system Japanese face before the Chinese fallback, but after the app's Latin faces. -/// Installing it repeatedly must not accumulate duplicate entries in any UI font family. -fn prioritize_system_japanese(fonts: &mut egui::FontDefinitions) { - for family in crate::theme::font_families() { - let stack = fonts.families.entry(family).or_default(); - stack.retain(|name| name != JAPANESE_FONT); - let before_chinese = stack - .iter() - .position(|name| name == "system-chinese" || name.starts_with("craft:")) - .unwrap_or(stack.len()); - stack.insert(before_chinese, JAPANESE_FONT.into()); - } -} - /// Japanese for the interface: true when built with the craft-fonts (already installed by -/// `theme::install`). Otherwise put the installed system Japanese face before the Chinese -/// fallbacks, preserving Latin fonts and the Chinese fallback for characters it alone covers. -/// Returns false when no Japanese font is installed; reapply after `theme::install` resets fonts. +/// `theme::install`). Otherwise add the system's Japanese font as the last fallback of every theme font family, from the next +/// pass on. Returns false (and changes nothing) when no Japanese font is installed. Call it again +/// after `theme::install`, which replaces the font definitions. pub fn install_japanese_font(ctx: &egui::Context) -> bool { if craft_japanese_font() { return true; } let Some(font) = system_japanese_font() else { return false }; - let mut fonts = ctx.fonts(|f| f.definitions().clone()); - fonts.font_data.insert(JAPANESE_FONT.into(), font); - prioritize_system_japanese(&mut fonts); - // Apply on the next pass, including when a theme change reinstalled the Latin fonts. - ctx.set_fonts(fonts); + let families = crate::theme::font_families() + .into_iter() + .map(|family| egui::epaint::text::InsertFontFamily { family, priority: egui::epaint::text::FontPriority::Lowest }) + .collect(); + // queued for the next pass (works before the first frame); a no-op when already installed + ctx.add_font(egui::epaint::text::FontInsert { name: JAPANESE_FONT.into(), data: (*font).clone(), families }); true } @@ -292,29 +279,6 @@ pub fn chinese_font_available() -> bool { mod tests { use super::*; - #[test] - fn system_japanese_fallback_precedes_chinese_in_all_ui_families() { - let mut fonts = egui::FontDefinitions::default(); - for family in crate::theme::font_families() { - let stack = fonts.families.entry(family).or_default(); - stack.insert(0, "app-latin".into()); - stack.push("craft:chinese fallback".into()); - stack.push("system-chinese".into()); - } - // Theme reinstalls and repeated language selections must not duplicate the font. - prioritize_system_japanese(&mut fonts); - prioritize_system_japanese(&mut fonts); - for family in crate::theme::font_families() { - let stack = &fonts.families[&family]; - assert_eq!(stack.first().map(String::as_str), Some("app-latin"), "{family:?}"); - let japanese = stack.iter().position(|name| name == JAPANESE_FONT).unwrap(); - let craft_chinese = stack.iter().position(|name| name == "craft:chinese fallback").unwrap(); - let system_chinese = stack.iter().position(|name| name == "system-chinese").unwrap(); - assert!(japanese > 0 && japanese < craft_chinese && japanese < system_chinese, "{family:?}: {stack:?}"); - assert_eq!(stack.iter().filter(|name| name.as_str() == JAPANESE_FONT).count(), 1); - } - } - #[test] fn catalogs_are_well_formed() { for (code, text) in [ From 2140d9c9aba92509177f7ff076a476865d005860 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 15:07:59 -0400 Subject: [PATCH 098/144] Restore green after the community batch: rustfmt, clippy as_chunks_mut in webm_alpha, zh-cn caption-track and es shutter-angle rows Co-Authored-By: Claude Opus 5.5 --- crates/captions/src/stl.rs | 7 +------ crates/captions/src/ttml.rs | 3 ++- crates/codecs/src/webm_alpha.rs | 6 ++---- crates/engine/src/commands.rs | 3 ++- crates/render/src/vfx/color.rs | 4 +--- crates/render/src/vfx/distort.rs | 5 ++--- crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/zh-cn.tsv | 4 ++++ crates/ui-egui/tests/timeline_rename_ui.rs | 4 +++- crates/ui-egui/tests/timeline_trim_ui.rs | 9 ++------- 10 files changed, 20 insertions(+), 26 deletions(-) diff --git a/crates/captions/src/stl.rs b/crates/captions/src/stl.rs index 21fb3353..da49f3b1 100644 --- a/crates/captions/src/stl.rs +++ b/crates/captions/src/stl.rs @@ -462,12 +462,7 @@ mod tests { fn literal_angle_brackets_are_not_discarded_as_markup() { let literal = "2 < 3 > 1, x < y > z, and 5 <7> 6"; let doc = Document { - cues: vec![Cue { - start: FrameRate::FPS_25.tick_of(25), - end: FrameRate::FPS_25.tick_of(75), - text: literal.into(), - ..Default::default() - }], + cues: vec![Cue { start: FrameRate::FPS_25.tick_of(25), end: FrameRate::FPS_25.tick_of(75), text: literal.into(), ..Default::default() }], ..Default::default() }; let encoded = write(&doc, Some(FrameRate::FPS_25)); diff --git a/crates/captions/src/ttml.rs b/crates/captions/src/ttml.rs index faff9dce..1987ba32 100644 --- a/crates/captions/src/ttml.rs +++ b/crates/captions/src/ttml.rs @@ -578,7 +578,8 @@ mod tests { assert!(parse_time("00:00:01:12.1", subframes).is_some()); assert_eq!(parse_time("00:00:01:12.2", subframes), None); // Bad cues must not wrap to a plausible but unrelated timeline position. - let xml = r#"

Bad

Good

"#; + let xml = + r#"

Bad

Good

"#; let doc = parse(xml).expect("well-formed TTML"); assert_eq!(doc.cues.len(), 1); assert_eq!(doc.cues[0].text, "Good"); diff --git a/crates/codecs/src/webm_alpha.rs b/crates/codecs/src/webm_alpha.rs index e602486e..e02c905d 100644 --- a/crates/codecs/src/webm_alpha.rs +++ b/crates/codecs/src/webm_alpha.rs @@ -228,10 +228,8 @@ pub(crate) fn set_alpha(frame: &mut VideoFrame, a: &VideoFrame) { } PixelData::Rgba8(rgba) => { let px = Arc::make_mut(rgba); - for (p, &v) in px.chunks_exact_mut(4).zip(&codes) { - if let Some(slot) = p.get_mut(3) { - *slot = rescale(v, 8) as u8; - } + for (p, &v) in px.as_chunks_mut::<4>().0.iter_mut().zip(&codes) { + p[3] = rescale(v, 8) as u8; } } PixelData::RgbaF32(_) => {} diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 33e2ee85..df6c80f4 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -2602,7 +2602,8 @@ fn build() -> Vec { }; (keyframes, v) }; - if e.effect == "ultra_key" && pid == "setting" + if e.effect == "ultra_key" + && pid == "setting" && let ParamValue::Choice(setting) = v && let Some(rows) = filmcraft_project::effect::ultra_key_setting(setting) { diff --git a/crates/render/src/vfx/color.rs b/crates/render/src/vfx/color.rs index 29c8f60a..309e6d72 100644 --- a/crates/render/src/vfx/color.rs +++ b/crates/render/src/vfx/color.rs @@ -162,9 +162,7 @@ pub fn ultra_key(img: &mut Image, e: &EffectInstance, cx: &FxCtx) { let kmag2 = (k[1] * k[1] + k[2] * k[2]).max(1e-6); let setting = chv(e, "setting"); let preset = filmcraft_project::effect::ultra_key_setting(setting); - let pf = |id: &str, fallback: f32| { - preset.and_then(|rows| rows.iter().find(|(k, _)| *k == id).map(|(_, v)| *v as f32)).unwrap_or(fallback) - }; + let pf = |id: &str, fallback: f32| preset.and_then(|rows| rows.iter().find(|(k, _)| *k == id).map(|(_, v)| *v as f32)).unwrap_or(fallback); let transparency = pf("transparency", fv(e, "transparency", cx)) / 100.0; let highlight = pf("highlight", fv(e, "highlight", cx)) / 100.0; let shadow = pf("shadow", fv(e, "shadow", cx)) / 100.0; diff --git a/crates/render/src/vfx/distort.rs b/crates/render/src/vfx/distort.rs index 95647857..db22ed10 100644 --- a/crates/render/src/vfx/distort.rs +++ b/crates/render/src/vfx/distort.rs @@ -257,9 +257,8 @@ pub fn transform_fx(img: &mut Image, e: &EffectInstance, cx: &FxCtx) { let skew = (crate::effects::f(e, "skew", &cx2) as f64).to_radians().tan(); let skew_axis = crate::effects::f(e, "skew_axis", &cx2) as f64; let op = crate::effects::f(e, "opacity", &cx2) / 100.0; - let sk = Affine::rotate_deg(skew_axis) - .then_apply(&Affine { a: 1.0, b: 0.0, c: skew, d: 1.0, e: 0.0, f: 0.0 }) - .then_apply(&Affine::rotate_deg(-skew_axis)); + let sk = + Affine::rotate_deg(skew_axis).then_apply(&Affine { a: 1.0, b: 0.0, c: skew, d: 1.0, e: 0.0, f: 0.0 }).then_apply(&Affine::rotate_deg(-skew_axis)); let m = Affine::translate(pos.x, pos.y) .then_apply(&Affine::rotate_deg(rot)) .then_apply(&sk) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index be95de1f..3f45ce9d 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3524,3 +3524,4 @@ Composite on the GPU when available Componer en la GPU cuando esté disponible Text to Speech Texto a voz Edit Narration Editar narración + Use Effect Shutter Angle Usar el ángulo de obturación del efecto diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index d41c22c4..e504e341 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -3502,3 +3502,7 @@ Clear Transition 清除过渡 Set Transition Duration 设置过渡持续时间 GPU acceleration is unavailable; preview renders on the CPU GPU 加速不可用;预览在 CPU 上渲染 + Caption Tracks 字幕轨道 + Delete Caption Track 删除字幕轨道 + Delete Caption Tracks 删除多个字幕轨道 + Delete Empty Caption Tracks 删除空字幕轨道 diff --git a/crates/ui-egui/tests/timeline_rename_ui.rs b/crates/ui-egui/tests/timeline_rename_ui.rs index 697fc81f..aa1ef4c9 100644 --- a/crates/ui-egui/tests/timeline_rename_ui.rs +++ b/crates/ui-egui/tests/timeline_rename_ui.rs @@ -30,7 +30,9 @@ fn rename_from_video_track_header_is_undoable() { let mut s = Session::default(); s.execute("file.openDemoProject", json!({})).expect("demo project"); let mut h = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_cc| FilmcraftApp::new(s)); - for _ in 0..5 { step(&mut h); } + for _ in 0..5 { + step(&mut h); + } let original = h.state().session.active_sequence().expect("active sequence").video_tracks[0].name.clone(); let id = h.state().session.active_sequence().expect("active sequence").video_tracks[0].id.0; click(&mut h, "timeline.track.V1.name", egui::PointerButton::Secondary); diff --git a/crates/ui-egui/tests/timeline_trim_ui.rs b/crates/ui-egui/tests/timeline_trim_ui.rs index a6be2841..bac77555 100644 --- a/crates/ui-egui/tests/timeline_trim_ui.rs +++ b/crates/ui-egui/tests/timeline_trim_ui.rs @@ -311,13 +311,8 @@ fn dragging_in_the_trim_monitor_trims_without_hanging() { d.frames(2); // the Program panel shows the two-up Trim Monitor once an edit point is selected let els = d.ok("ui.elements", json!({"prefix": "trimMonitor."})); - let out = els - .as_array() - .expect("elements") - .iter() - .find(|e| e["id"] == "trimMonitor.outgoing") - .unwrap_or_else(|| panic!("trim monitor not shown: {els}")) - .clone(); + let out = + els.as_array().expect("elements").iter().find(|e| e["id"] == "trimMonitor.outgoing").unwrap_or_else(|| panic!("trim monitor not shown: {els}")).clone(); let r: Vec = out["rect"].as_array().expect("rect").iter().map(|v| v.as_f64().expect("number") as f32).collect(); let (cx, cy) = (r[0] + r[2] / 2.0, r[1] + r[3] / 2.0); // about 66 px of drag = the 6 pt threshold + 60 px of motion, at 6 px per frame; From 110c1783c343cb25f021e0a9c31a7bb7f2b5283b Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 21:09:13 +0200 Subject: [PATCH 099/144] #620: Project Notes panel, saved in the project Problem: a project had nowhere to keep free text (to-dos, instructions for whoever opens it next, frame numbers). Fix: plain-text `Project::notes` (schema v15, no-op migration so older builds refuse instead of dropping it), `project.notes` / `project.setNotes {text, merge?}` engine commands (undoable, capped at 1 MiB; calls with one `merge` key share an undo step), and a Window > Project Notes panel whose editor (`notes.text`) commits every change, one typing session per undo step. Translations for es, ja, zh-cn (and the panel title for pt-br, uk). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/panels.rs | 44 +++++++++++++++++++++++++ crates/engine/src/panels_tests.rs | 37 +++++++++++++++++++++ crates/format/src/lib.rs | 11 +++++-- crates/project/src/lib.rs | 5 +++ crates/ui-egui/src/dock.rs | 5 ++- crates/ui-egui/src/i18n/es.tsv | 4 +++ crates/ui-egui/src/i18n/ja.tsv | 4 +++ crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/zh-cn.tsv | 4 +++ crates/ui-egui/src/panels/mod.rs | 2 ++ crates/ui-egui/src/panels/notes.rs | 53 ++++++++++++++++++++++++++++++ crates/ui-egui/tests/panels_ui.rs | 28 +++++++++++++++- docs/architecture.md | 3 +- 14 files changed, 197 insertions(+), 5 deletions(-) create mode 100644 crates/ui-egui/src/panels/notes.rs diff --git a/crates/engine/src/panels.rs b/crates/engine/src/panels.rs index 45da2e21..6246e7a3 100644 --- a/crates/engine/src/panels.rs +++ b/crates/engine/src/panels.rs @@ -4,6 +4,7 @@ //! |---|---| //! | `events.list` / `events.clear` | the Events panel: warnings and errors of commands, background jobs and the auto-save worker ([`EventLog`]) | //! | `metadata.get` / `metadata.set` | the Metadata panel: an item's clip and file properties; editable log fields (undoable) | +//! | `project.notes` / `project.setNotes` | the Project Notes panel: free plain text saved in the project (undoable) | //! //! The Progress panel uses `jobs.list` / `jobs.cancel`; the Timecode panel and the Reference //! Monitor are frontend state only. @@ -339,6 +340,38 @@ fn metadata_set(s: &mut Session, p: &Value) -> Result { Ok(json!({"item": id.0, "name": it.name, "label": it.label.name(), "metadata": it.metadata, "changed": !unchanged})) } +// ------------------------------------------------------------------------------------ project notes + +/// Longest project notes accepted (bytes of UTF-8), so a script or a pasted file cannot grow the +/// project (and every undo step) without bound. +pub const MAX_NOTES_BYTES: usize = 1 << 20; + +/// `project.notes`: the project notes. +fn notes_get(s: &mut Session, _: &Value) -> Result { + Ok(json!({"text": s.project.notes})) +} + +/// `project.setNotes {text, merge?}`: replace the project notes as one undo step. Calls with the +/// same `merge` key and no other edit in between share that step (typing in the panel). +fn notes_set(s: &mut Session, p: &Value) -> Result { + let text = p.get("text").and_then(Value::as_str).ok_or_else(|| bad("project.setNotes", "need `text` (a string)"))?; + if text.len() > MAX_NOTES_BYTES { + return Err(bad("project.setNotes", format!("the notes are too long ({} bytes; at most {MAX_NOTES_BYTES})", text.len()))); + } + let changed = s.project.notes != text; + if changed { + let body = |pr: &mut Project, _: &mut crate::EditorState| { + pr.notes = text.to_string(); + Ok(()) + }; + match str_p(p, "merge") { + Some(key) => s.edit_merged("Edit Project Notes", &format!("project.notes:{key}"), body)?, + None => s.edit("Edit Project Notes", body)?, + } + } + Ok(json!({"text": s.project.notes, "changed": changed})) +} + // ------------------------------------------------------------------------------------ registry pub(crate) fn commands() -> Vec { @@ -370,5 +403,16 @@ pub(crate) fn commands() -> Vec { run: metadata_set, journal: true, }, + q("project.notes", "Get Project Notes", "{}", notes_get), + CommandSpec { + id: "project.setNotes", + label: "Edit Project Notes", + menu: &[], + shortcut: None, + params: r#"{"text":str,"merge":str?}"#, + enabled: always, + run: notes_set, + journal: true, + }, ] } diff --git a/crates/engine/src/panels_tests.rs b/crates/engine/src/panels_tests.rs index 357e995a..8be530ab 100644 --- a/crates/engine/src/panels_tests.rs +++ b/crates/engine/src/panels_tests.rs @@ -156,3 +156,40 @@ fn events_report_background_jobs() { s.poll_persistence(); assert_eq!(s.log.entries.len(), 6); } + +/// #620: project notes are saved in the project file; typing (one `merge` key) is one undo step. +#[test] +fn project_notes_are_saved_and_typing_is_one_undo_step() { + let mut s = demo(); + assert_eq!(s.execute("project.notes", json!({})).unwrap()["text"], ""); + let undo0 = s.history.undo.len(); + for text in ["T", "To", "To do: fix the logo at 00:01:02:03"] { + s.execute("project.setNotes", json!({"text": text, "merge": "1"})).unwrap(); + } + assert_eq!(s.history.undo.len(), undo0 + 1, "one typing session, one undo step"); + assert_eq!(s.history.undo.last().unwrap().0, "Edit Project Notes"); + // the same text again changes nothing and adds no step + let r = s.execute("project.setNotes", json!({"text": "To do: fix the logo at 00:01:02:03"})).unwrap(); + assert_eq!(r["changed"], json!(false)); + assert_eq!(s.history.undo.len(), undo0 + 1); + + let loaded = filmcraft_format::decode(&filmcraft_format::encode(&s.project, false)).unwrap(); + assert_eq!(loaded.project.notes, "To do: fix the logo at 00:01:02:03"); + // an older file without notes still loads, with none + let mut doc: Value = serde_json::from_slice(&filmcraft_format::encode(&filmcraft_project::Project::new("old"), false)).unwrap(); + doc["schema_version"] = json!(14); + assert_eq!(filmcraft_format::decode(&serde_json::to_vec(&doc).unwrap()).unwrap().project.notes, ""); + + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.project.notes, ""); +} + +#[test] +fn project_notes_refuse_bad_input() { + let mut s = demo(); + assert!(s.execute("project.setNotes", json!({})).is_err()); + assert!(s.execute("project.setNotes", json!({"text": 5})).is_err()); + let huge = "x".repeat(crate::panels::MAX_NOTES_BYTES + 1); + assert!(s.execute("project.setNotes", json!({"text": huge})).is_err()); + assert_eq!(s.project.notes, ""); +} diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 238649af..70af3c74 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -37,6 +37,8 @@ //! `audio` objects read as stream zero; older builds must refuse new multi-stream projects. //! - **v14** (M7.9): narrations (`Project::narrations`, Text to Speech). No-op step; older builds //! would drop them when saving. +//! - **v15**: project notes (`Project::notes`, Window ▸ Project Notes). No-op step; older builds +//! would drop them when saving. #![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))] @@ -58,7 +60,7 @@ pub type Migration = fn(Value) -> Result; /// `MIGRATIONS[i]` upgrades schema `i + 1` to `i + 2`. Append one function per schema bump; never /// edit a shipped one. pub const MIGRATIONS: &[Migration] = - &[v1_to_v2, v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, v6_to_v7, v7_to_v8, v8_to_v9, v9_to_v10, v10_to_v11, v11_to_v12, v12_to_v13, v13_to_v14]; + &[v1_to_v2, v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, v6_to_v7, v7_to_v8, v8_to_v9, v9_to_v10, v10_to_v11, v11_to_v12, v12_to_v13, v13_to_v14, v14_to_v15]; /// The schema version this build writes (and the newest it reads). pub const SCHEMA_VERSION: u32 = MIGRATIONS.len() as u32 + 1; @@ -354,13 +356,18 @@ fn v13_to_v14(doc: Value) -> Result { Ok(doc) } +/// v14 → v15: project notes. Existing data needs no change. +fn v14_to_v15(doc: Value) -> Result { + Ok(doc) +} + #[cfg(test)] mod tests { use super::*; #[test] fn schema_version_matches_table() { - assert_eq!(SCHEMA_VERSION, 14); + assert_eq!(SCHEMA_VERSION, 15); } #[test] diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index e8a98856..aca499f9 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -1253,6 +1253,10 @@ pub struct Project { /// made from, keyed by that item. Schema v14. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub narrations: BTreeMap, + /// Project notes (Window ▸ Project Notes): free plain text kept with the project, for to-dos, + /// instructions for whoever opens it next, frame numbers and the like. Schema v15. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub notes: String, } /// How a sequence is shown in the Timeline panel: its zoom, scroll position and track heights. @@ -1348,6 +1352,7 @@ impl Project { search_bins: Vec::new(), source_graphics: BTreeMap::new(), narrations: BTreeMap::new(), + notes: String::new(), } } diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 05acf45b..d2242b26 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -37,10 +37,11 @@ pub enum PanelKind { Progress, ReferenceMonitor, Timecode, + ProjectNotes, } impl PanelKind { - pub const ALL: [PanelKind; 27] = [ + pub const ALL: [PanelKind; 28] = [ PanelKind::Project, PanelKind::MediaBrowser, PanelKind::Libraries, @@ -68,6 +69,7 @@ impl PanelKind { PanelKind::Progress, PanelKind::ReferenceMonitor, PanelKind::Timecode, + PanelKind::ProjectNotes, ]; pub fn title(self) -> &'static str { match self { @@ -98,6 +100,7 @@ impl PanelKind { PanelKind::Progress => "Progress", PanelKind::ReferenceMonitor => "Reference Monitor", PanelKind::Timecode => "Timecode", + PanelKind::ProjectNotes => "Project Notes", } } pub fn id(self) -> String { diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 08f899ec..0b03b988 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3512,3 +3512,7 @@ Composite on the GPU when available Componer en la GPU cuando esté disponible Text to Speech Texto a voz Edit Narration Editar narración + Project Notes Notas del proyecto + Get Project Notes Obtener notas del proyecto + Edit Project Notes Editar notas del proyecto + Notes for this project: to-dos, instructions, frame numbers… Saved with the project. Notas de este proyecto: tareas pendientes, instrucciones, números de fotograma… Se guardan con el proyecto. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 8dbf4419..ed58e821 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3505,3 +3505,7 @@ Edit Narration ナレーションを編集 Clear Transition トランジションを消去 Set Transition Duration トランジションのデュレーションを設定 + Project Notes プロジェクトメモ + Get Project Notes プロジェクトメモを取得 + Edit Project Notes プロジェクトメモを編集 + Notes for this project: to-dos, instructions, frame numbers… Saved with the project. このプロジェクトのメモ: やること、指示、フレーム番号など… プロジェクトと一緒に保存されます。 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 1e9c995e..fc7b9fd5 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -355,3 +355,4 @@ # System appearance mode menu command. Next Appearance Mode Próximo modo de aparência + Project Notes Notas do projeto diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 6fa303f4..13312b31 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -355,3 +355,4 @@ Zoom In Збільшити Zoom Out Зменшити Zoom to Sequence Умістити секвенцію + Project Notes Нотатки проєкту diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index 731067f9..e39c7f50 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -3494,3 +3494,7 @@ Edit Narration 编辑旁白 Clear Transition 清除过渡 Set Transition Duration 设置过渡持续时间 + Project Notes 项目备注 + Get Project Notes 获取项目备注 + Edit Project Notes 编辑项目备注 + Notes for this project: to-dos, instructions, frame numbers… Saved with the project. 此项目的备注:待办事项、说明、帧号等… 随项目一起保存。 diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index bcb386fb..0e7b20d1 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -29,6 +29,7 @@ pub mod mixer; pub mod monitor; pub mod monitor_view; pub mod multicam; +pub mod notes; pub mod panel_state; pub mod presets; pub mod project; @@ -101,6 +102,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rect) PanelKind::Text => text::show(app, ui, rect), PanelKind::EssentialSound => essential_sound::show(app, ui, rect), PanelKind::TextToSpeech => tts::show(app, ui, rect), + PanelKind::ProjectNotes => notes::show(app, ui, rect), other => crate::dock::placeholder(ui, rect, &app.tokens, &tlf!("{panel} — coming in a later milestone", panel = crate::i18n::t(other.title()))), } } diff --git a/crates/ui-egui/src/panels/notes.rs b/crates/ui-egui/src/panels/notes.rs new file mode 100644 index 00000000..740db1f4 --- /dev/null +++ b/crates/ui-egui/src/panels/notes.rs @@ -0,0 +1,53 @@ +//! Project Notes panel (#620): free plain text kept in the project file, for to-dos, +//! instructions for whoever opens the project next, frame numbers and the like. Every change is +//! a `project.setNotes`; one typing session (from focusing the editor until it loses focus) is +//! one undo step. +//! +//! Automation ids: `notes.text` (the editor; its label is the start of the notes). Agents read +//! and write the notes with `project.notes` / `project.setNotes`. + +use egui::{Rect, vec2}; +use serde_json::json; + +use crate::FilmcraftApp; + +/// Longest automation label (characters): the full notes are `project.notes`. +const LABEL_CHARS: usize = 200; + +pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { + let t = app.tokens; + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(vec2(8.0, 6.0)))); + let ui = &mut child; + let mut text = app.session.project.notes.clone(); + let height = ui.available_height(); + let r = egui::ScrollArea::vertical() + .id_salt("project-notes-scroll") + .auto_shrink([false, false]) + .show(ui, |ui| { + ui.add( + egui::TextEdit::multiline(&mut text) + .id_salt("project-notes") + .hint_text(tl!("Notes for this project: to-dos, instructions, frame numbers… Saved with the project.")) + .text_color(t.text) + .desired_width(f32::INFINITY) + .min_size(vec2(0.0, height)), + ) + }) + .inner; + // each focus of the editor starts a new undo step; the edits while it keeps focus join it + let typing = egui::Id::new("project-notes-typing"); + if r.gained_focus() { + ui.data_mut(|d| { + let n = d.get_temp::(typing).unwrap_or(0).wrapping_add(1); + d.insert_temp(typing, n); + }); + } + if r.changed() { + let key = ui.data(|d| d.get_temp::(typing)).unwrap_or(0); + if let Err(e) = app.session.execute("project.setNotes", json!({"text": text, "merge": key.to_string()})) { + app.ui.status = e.to_string(); + } + } + let label: String = app.session.project.notes.chars().take(LABEL_CHARS).collect(); + app.auto.add("notes.text", r.rect, &label); +} diff --git a/crates/ui-egui/tests/panels_ui.rs b/crates/ui-egui/tests/panels_ui.rs index e395f4a8..d548d6cb 100644 --- a/crates/ui-egui/tests/panels_ui.rs +++ b/crates/ui-egui/tests/panels_ui.rs @@ -158,7 +158,7 @@ fn every_panel_opens_from_the_window_menu() { let menu = d.ok("ui.menu.list", json!({})); let window: Vec = menu.as_array().unwrap().iter().filter(|m| m["path"] == json!(["Window"])).filter_map(|m| m["label"].as_str().map(str::to_string)).collect(); - for p in ["Events", "Lumetri Scopes", "Metadata", "Progress", "Reference Monitor", "Timecode"] { + for p in ["Events", "Lumetri Scopes", "Metadata", "Progress", "Project Notes", "Reference Monitor", "Timecode"] { assert!(window.iter().any(|w| w == p), "{p} not in Window: {window:?}"); } for (panel, key) in [ @@ -168,6 +168,7 @@ fn every_panel_opens_from_the_window_menu() { ("Events", "events.clearAll"), ("Progress", "progress.showFinished"), ("ReferenceMonitor", "reference.gang"), + ("ProjectNotes", "notes.text"), ] { d.ok("ui.menu.invoke", json!({"id": format!("window.panel.{panel}")})); d.frames(3); @@ -267,6 +268,31 @@ fn metadata_panel_edits_a_field_with_undo() { assert_eq!(d.label("metadata.field.TapeName"), "A001"); } +/// #620: notes typed into Project Notes land in the project; one typing session is one undo step. +#[test] +fn project_notes_panel_types_into_the_project_with_undo() { + let mut d = Driver::demo(); + d.ok("ui.menu.invoke", json!({"id": "window.panel.ProjectNotes"})); + d.frames(3); + let undo0 = d.app().session.history.undo.len(); + d.click("notes.text"); + d.ok("ui.type", json!({"text": "Fix logo"})); + d.frames(2); + d.ok("ui.key", json!({"key": "Enter"})); + d.ok("ui.type", json!({"text": "at 1:02"})); + d.frames(3); + assert_eq!(d.app().session.project.notes, "Fix logo\nat 1:02"); + assert_eq!(d.app().session.history.undo.len(), undo0 + 1); + assert_eq!(d.label("notes.text"), "Fix logo\nat 1:02"); + d.exec("edit.undo", json!({})); + d.frames(2); + assert_eq!(d.app().session.project.notes, ""); + // an agent writes them, the panel shows them + d.exec("project.setNotes", json!({"text": "From the script"})); + d.frames(2); + assert_eq!(d.label("notes.text"), "From the script"); +} + #[test] fn timecode_panel_rows_and_modes() { let mut d = Driver::demo(); diff --git a/docs/architecture.md b/docs/architecture.md index a64d130f..f9e005f7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -729,7 +729,8 @@ All of these dispatch the same command ids. - **Panels with engine data** (M8.9 / M12.6): Lumetri Scopes (`scopes.read` returns the same scopes as numbers), Metadata (`metadata.get` / `metadata.set`, one undo step per edit, stored in `ProjectItem::metadata`), Events (`events.list` / `events.clear`), Progress (`jobs.list` / - `jobs.cancel`). Timecode and Reference Monitor are frontend-only views. Their settings are + `jobs.cancel`), Project Notes (`project.notes` / `project.setNotes`, plain text in + `Project::notes`, schema v15; one typing session is one undo step). Timecode and Reference Monitor are frontend-only views. Their settings are `UiState::panels` (`ui.set {"panels": {...}}`). - **Project panel and Media Browser** (M12.7). View settings (List / Icon / Freeform, thumbnail and font size, Preview Area, Hover Scrub), the List view's columns, widths and sort, and ten view From 6f650a363abc204626cc6c17fc989eb6c7b7f9bd Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 21:13:42 +0200 Subject: [PATCH 100/144] #456: a bin can be dragged into another bin and back out Problem: a bin stayed wherever it was created. `project.moveToBin` only moved project items, and bin rows and cards in the Project panel could not be dragged, so the only way to nest bins was to create them inside each other. Fix: `project.moveToBin` also takes bin ids in `items` (as `project.delete` does) and moves the bin with everything in it, skipping the root and any move that would put a bin inside itself. In the Project panel, bin rows (List view) and bin cards (Icon view) can be dragged: dropping one on another bin nests it there, and dropping it on empty space moves it into the bin the view shows. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands.rs | 2 +- crates/project/src/lib.rs | 54 ++++++++++++++++++---- crates/ui-egui/src/panels/mod.rs | 12 +++++ crates/ui-egui/src/panels/project_views.rs | 44 +++++++++++++++--- crates/ui-egui/tests/project_panel_ui.rs | 35 ++++++++++++++ 5 files changed, 132 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 33e2ee85..a53dc12c 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -2765,7 +2765,7 @@ fn build() -> Vec { s.fix_state(); Ok(json!({"items": items.len(), "bins": bins.len()})) }), - cmd!("project.moveToBin", "Move to Bin", [], None, r#"{"items":[id]?,"bin":binId|null}"#, always, |s, p| { + cmd!("project.moveToBin", "Move to Bin", [], None, r#"{"items":[id|binId]?,"bin":binId|null}"#, always, |s, p| { let items: Vec = match p.get("items").and_then(Value::as_array) { Some(a) => a.iter().filter_map(|v| v.as_u64().map(ItemId)).collect(), None => s.state.project_selection.clone(), diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index e8a98856..b8ff0759 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -1380,20 +1380,35 @@ impl Project { id } - /// Move items into a bin (the root when `bin` is None). Returns how many moved; unknown items - /// are skipped. Fails (returns None) when the bin does not exist. + /// Move items, and bins named by id, into a bin (the root when `bin` is None). A bin goes with + /// everything in it. Returns how many moved; unknown ids are skipped, and so is a bin that + /// would land in itself or in one of its own sub-bins. Fails (returns None) when the target + /// bin does not exist. pub fn move_to_bin(&mut self, items: &[ItemId], bin: Option) -> Option { let target = bin.unwrap_or(self.root.id); self.root.find_bin(target)?; let mut moved = 0; for &id in items { - if !self.items.contains_key(&id) || !self.root.remove_item(id) { - continue; - } - if let Some(b) = self.root.find_bin_mut(target) { - b.children.push(BinEntry::Item(id)); - moved += 1; + let entry = if self.items.contains_key(&id) { + if !self.root.remove_item(id) { + continue; + } + BinEntry::Item(id) + } else { + let b = BinId(id.0); + // never the root, nor a bin that holds the target (or is it) + if b == self.root.id || self.root.find_bin(b).is_none_or(|x| x.find_bin(target).is_some()) { + continue; + } + let Some(sub) = self.root.remove_bin(b) else { continue }; + BinEntry::Bin(sub) + }; + match self.root.find_bin_mut(target) { + Some(t) => t.children.push(entry), + // cannot happen (the target is outside what was taken out), but never lose it + None => self.root.children.push(entry), } + moved += 1; } Some(moved) } @@ -1994,6 +2009,29 @@ mod tests { assert_eq!(p.root.parent_of(i), None); } + #[test] + fn a_bin_moves_into_another_bin_with_its_contents() { + let mut p = Project::new("x"); + let outer = p.add_bin("Footage", None); + let inner = p.add_bin("B-Roll", None); + let deep = p.add_bin("Drone", Some(inner)); + let i = p.add_item("a", Label::Iris, ItemKind::AdjustmentLayer { width: 10, height: 10, rate: FrameRate::FPS_24, duration: Tick(1) }, Some(deep)); + assert_eq!(p.move_to_bin(&[ItemId(inner.0)], Some(outer)), Some(1)); + let o = p.root.find_bin(outer).unwrap(); + assert!(o.children.iter().any(|c| matches!(c, BinEntry::Bin(b) if b.id == inner))); + assert!(!p.root.children.iter().any(|c| matches!(c, BinEntry::Bin(b) if b.id == inner))); + assert_eq!(p.root.parent_of(i), Some(deep)); + assert!(o.find_bin(deep).is_some()); + // never into itself or its own sub-bin, and the root never moves + assert_eq!(p.move_to_bin(&[ItemId(outer.0)], Some(outer)), Some(0)); + assert_eq!(p.move_to_bin(&[ItemId(outer.0)], Some(deep)), Some(0)); + assert_eq!(p.move_to_bin(&[ItemId(p.root.id.0)], Some(outer)), Some(0)); + assert!(p.root.find_bin(outer).unwrap().find_bin(deep).is_some()); + // and back out to the root + assert_eq!(p.move_to_bin(&[ItemId(inner.0)], None), Some(1)); + assert!(p.root.children.iter().any(|c| matches!(c, BinEntry::Bin(b) if b.id == inner))); + } + #[test] fn project_clone_shares_sequences_until_mutated() { fn sequence_arc(p: &Project, id: ItemId) -> &std::sync::Arc { diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index bcb386fb..5ba2abf8 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -111,6 +111,8 @@ pub mod source_range; #[derive(Clone, Debug)] enum DragPayload { Item(ItemId), + /// A bin (by id) dragged in the Project panel. + Bin(u64), Source(source_drag::SourceDrag), Effect(String), /// A graphics template (id, name) from Essential Graphics ▸ Browse. @@ -133,6 +135,15 @@ pub fn dragged_source(ui: &egui::Ui) -> Option { pub fn start_drag_item(ui: &egui::Ui, item: ItemId) { ui.ctx().data_mut(|d| d.insert_temp(payload_id(), Some(DragPayloadBox(DragPayload::Item(item))))); } +pub fn start_drag_bin(ui: &egui::Ui, bin: u64) { + ui.ctx().data_mut(|d| d.insert_temp(payload_id(), Some(DragPayloadBox(DragPayload::Bin(bin))))); +} +pub fn dragged_bin(ui: &egui::Ui) -> Option { + match payload(ui) { + Some(DragPayload::Bin(b)) => Some(b), + _ => None, + } +} pub fn start_drag_template(ui: &egui::Ui, id: &str, name: &str) { ui.ctx().data_mut(|d| d.insert_temp(payload_id(), Some(DragPayloadBox(DragPayload::Template(id.to_string(), name.to_string()))))); } @@ -175,6 +186,7 @@ pub fn drag_ghost(app: &FilmcraftApp, ui: &egui::Ui) { if let Some(p) = ctx.pointer_hover_pos() { let label = match &pl { DragPayload::Item(i) => app.session.project.item(*i).map(|x| x.name.clone()).unwrap_or_default(), + DragPayload::Bin(b) => app.session.project.root.find_bin(filmcraft_project::BinId(*b)).map(|x| x.name.clone()).unwrap_or_default(), DragPayload::Source(s) => { let name = app.session.project.item(s.item).map(|i| i.name.as_str()).unwrap_or("Source"); let mode = match (s.video, s.audio) { diff --git a/crates/ui-egui/src/panels/project_views.rs b/crates/ui-egui/src/panels/project_views.rs index 46d80023..76cafdbe 100644 --- a/crates/ui-egui/src/panels/project_views.rs +++ b/crates/ui-egui/src/panels/project_views.rs @@ -5,7 +5,7 @@ use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use filmcraft_engine::project_panel::{self as pp, Card, SortSpec, ViewMode}; -use filmcraft_project::{Bin, BinEntry, ItemId, ItemKind}; +use filmcraft_project::{Bin, BinEntry, BinId, ItemId, ItemKind}; use filmcraft_time::Tick; use serde_json::json; @@ -331,21 +331,46 @@ pub fn clear_params(selection: &[filmcraft_project::ItemId], selected_bin: Optio } /// Drop project items dragged onto a bin: move them (the selection when the dragged item is in it). +/// A dragged bin nests in it, unless that would put the bin inside itself. fn accept_bin_drop(app: &FilmcraftApp, ui: &egui::Ui, r: Rect, bin: u64, actions: &mut Actions) -> bool { - let Some(item) = crate::panels::dragged_project_item(ui) else { return false }; + let items: Vec = if let Some(item) = crate::panels::dragged_project_item(ui) { + let sel = &app.session.state.project_selection; + if sel.contains(&item) { sel.iter().map(|i| i.0).collect() } else { vec![item.0] } + } else if let Some(dragged) = crate::panels::dragged_bin(ui) { + let root = &app.session.project.root; + if root.find_bin(BinId(dragged)).is_none_or(|d| d.find_bin(BinId(bin)).is_some()) { + return false; + } + vec![dragged] + } else { + return false; + }; if !ui.rect_contains_pointer(r) { return false; } ui.painter().rect_stroke(r, 3.0, Stroke::new(1.5, app.tokens.accent), StrokeKind::Inside); if ui.input(|i| i.pointer.any_released()) { - let sel = &app.session.state.project_selection; - let items: Vec = if sel.contains(&item) { sel.iter().map(|i| i.0).collect() } else { vec![item.0] }; actions.push(("project.moveToBin".into(), json!({"items": items, "bin": bin}))); crate::panels::clear_drag(ui); } true } +/// A bin dropped on empty space moves into the bin the view shows (out of the bin it was nested in). +fn accept_view_drop(app: &FilmcraftApp, ui: &egui::Ui, r: Rect, v: &View, actions: &mut Actions) { + let Some(dragged) = crate::panels::dragged_bin(ui) else { return }; + let root = &app.session.project.root; + // already shown here, or the view is inside the dragged bin + let here = root.find_bin(v.bin).is_some_and(|b| b.children.iter().any(|c| matches!(c, BinEntry::Bin(x) if x.id.0 == dragged))); + let inside = root.find_bin(BinId(dragged)).is_none_or(|d| d.find_bin(v.bin).is_some()); + if here || inside || !ui.rect_contains_pointer(r) || !ui.input(|i| i.pointer.any_released()) { + return; + } + let bin = (v.bin != root.id).then_some(v.bin.0); + actions.push(("project.moveToBin".into(), json!({"items": [dragged], "bin": bin}))); + crate::panels::clear_drag(ui); +} + /// The inline rename field at `r`; true while it is shown. fn rename_field(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, item: Option, bin: Option, pre: &str) -> bool { let Some(rn) = app.ui.project_panel.rename.clone() else { return false }; @@ -521,6 +546,7 @@ fn empty_space(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &View, bg: &egui::R ui.allocate_rect(r, Sense::hover()); app.auto.add(&format!("{}.empty", v.prefix), r.intersect(ui.clip_rect()), "Empty area"); marquee(app, ui, v, bg, ui.max_rect().min, actions); + accept_view_drop(app, ui, r, v, actions); if bg.clicked() { app.ui.project_panel.selected_bin = None; actions.push(("project.select".into(), json!({"items": []}))); @@ -614,7 +640,7 @@ fn list_bin(app: &mut FilmcraftApp, ui: &mut egui::Ui, bin: &Bin, depth: usize, for bid in bins { let Some(b) = app.session.project.root.find_bin(bid).cloned() else { continue }; let open = app.ui.expanded_bins.contains(&b.id.0) || !filter.is_empty(); - let (r, resp) = ui.allocate_exact_size(vec2(lc.width, lc.row_h), Sense::click()); + let (r, resp) = ui.allocate_exact_size(vec2(lc.width, lc.row_h), Sense::click_and_drag()); let selected = app.ui.project_panel.selected_bin == Some(b.id.0); if selected { ui.painter().rect_filled(r, 0.0, t.row_selected); @@ -633,6 +659,9 @@ fn list_bin(app: &mut FilmcraftApp, ui: &mut egui::Ui, bin: &Bin, depth: usize, app.auto.add(&format!("{}.bin.{}", v.prefix, b.id.0), r.intersect(ui.clip_rect()), &b.name); app.auto.add(&format!("{}.bin.{}.toggle", v.prefix, b.id.0), tri, "Expand"); accept_bin_drop(app, ui, r, b.id.0, actions); + if resp.drag_started() { + crate::panels::start_drag_bin(ui, b.id.0); + } if resp.clicked() { let on_tri = resp.interact_pointer_pos().is_some_and(|p| p.x < x + 12.0); if on_tri || app.ui.project_panel.selected_bin == Some(b.id.0) && !resp.double_clicked() { @@ -844,7 +873,7 @@ fn card(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, id: ItemId, pre: &st fn bin_card(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, b: &Bin, v: &View, actions: &mut Actions) { let t = app.tokens; let th = Rect::from_min_size(r.min, vec2(r.width(), r.width() * 9.0 / 16.0)); - let resp = ui.interact(r.expand(2.0), egui::Id::new((&v.prefix, "bincard", b.id.0)), Sense::click()); + let resp = ui.interact(r.expand(2.0), egui::Id::new((&v.prefix, "bincard", b.id.0)), Sense::click_and_drag()); let selected = app.ui.project_panel.selected_bin == Some(b.id.0); let bg = Rect::from_min_max(th.min - vec2(6.0, 6.0), pos2(th.max.x + 6.0, r.max.y + 2.0)); ui.painter().rect_filled(bg, 4.0, if selected { t.tl_header_bg } else { t.panel_bg }); @@ -863,6 +892,9 @@ fn bin_card(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, b: &Bin, v: &Vie } app.auto.add(&format!("{}.bin.{}", v.prefix, b.id.0), th, &b.name); accept_bin_drop(app, ui, th, b.id.0, actions); + if resp.drag_started() { + crate::panels::start_drag_bin(ui, b.id.0); + } if resp.clicked() { app.ui.project_panel.selected_bin = Some(b.id.0); actions.push(("project.select".into(), json!({"items": []}))); diff --git a/crates/ui-egui/tests/project_panel_ui.rs b/crates/ui-egui/tests/project_panel_ui.rs index 3fac337e..77ff7f1c 100644 --- a/crates/ui-egui/tests/project_panel_ui.rs +++ b/crates/ui-egui/tests/project_panel_ui.rs @@ -485,6 +485,41 @@ fn select_all_takes_only_the_shown_bin() { assert_eq!(sorted(&d.app().session.state.project_selection), footage); } +#[test] +fn a_dragged_bin_nests_in_another_and_back_out() { + // #456: a bin stayed wherever it was created; dragging it onto another bin did nothing + let mut d = project_driver(); + d.exec("project.view.set", json!({"view": "list"})); + let outer = d.exec("file.newBin", json!({"name": "Outer"}))["bin"].as_u64().unwrap(); + let inner = d.exec("file.newBin", json!({"name": "Inner"}))["bin"].as_u64().unwrap(); + d.app().ui.expanded_bins.clear(); + d.frames(3); + let parent = + |d: &mut Driver, b: u64| filmcraft_ui_egui::panels::project::parent_bin(&d.app().session.project.root, filmcraft_project::BinId(b)).map(|p| p.0); + let root = d.app().session.project.root.id.0; + let drag = |d: &mut Driver, from: [f32; 4], to: (f32, f32)| { + let start = (from[0] + 80.0, from[1] + from[3] / 2.0); + d.ok("ui.drag", json!({"from": {"x": start.0, "y": start.1}, "to": {"x": to.0, "y": to.1}, "steps": 12})); + d.frames(3); + }; + // onto the Outer row: Inner nests in it + let (from, to) = (d.rect(&format!("project.bin.{inner}")), d.rect(&format!("project.bin.{outer}"))); + drag(&mut d, from, (to[0] + 80.0, to[1] + to[3] / 2.0)); + assert_eq!(parent(&mut d, inner), Some(outer)); + // a bin never goes into itself + d.app().ui.expanded_bins.push(outer); + d.frames(3); + let (from, to) = (d.rect(&format!("project.bin.{outer}")), d.rect(&format!("project.bin.{inner}"))); + drag(&mut d, from, (to[0] + 80.0, to[1] + to[3] / 2.0)); + assert_eq!(parent(&mut d, outer), Some(root)); + assert_eq!(parent(&mut d, inner), Some(outer)); + // onto empty space: back to the bin the view shows + let from = d.rect(&format!("project.bin.{inner}")); + let to = centre(d.rect("project.empty")); + drag(&mut d, from, to); + assert_eq!(parent(&mut d, inner), Some(root)); +} + #[test] fn footer_buttons_are_wired() { let mut d = project_driver(); From 78a3fea2229a762aa0d70dd0d5b82ddc90c38f64 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 21:21:26 +0200 Subject: [PATCH 101/144] #227: Lumetri presets can be dragged onto a Timeline clip Problem: in the Effects panel, the Lumetri Presets rows and the wide-panel thumbnail grid only sensed clicks. They applied through double-click or the Apply menu, never through a drag, unlike every other effect and preset. Fix: rows and grid cells sense drags and start an effect payload `lumetri:`. The Timeline's effect drop applies it to the clip under the pointer with `lumetri.applyPreset` (one undo step). The drag ghost shows the preset's name. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/effects.rs | 13 ++++++++++--- crates/ui-egui/src/panels/mod.rs | 14 +++++++++++--- crates/ui-egui/src/panels/timeline.rs | 2 ++ crates/ui-egui/tests/color_ui.rs | 23 +++++++++++++++++++++++ 4 files changed, 46 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/panels/effects.rs b/crates/ui-egui/src/panels/effects.rs index 76f4f8c2..df3e7d93 100644 --- a/crates/ui-egui/src/panels/effects.rs +++ b/crates/ui-egui/src/panels/effects.rs @@ -3,7 +3,8 @@ //! //! Lumetri Presets: each preset row has a thumbnail (our Lumetri on the procedural preview //! picture, `filmcraft_render::lumetri_presets`); double-click (or right-click ▸ Apply to Selected -//! Clips, `effects.presetMenu.apply`) applies it (`lumetri.applyPreset`). +//! Clips, `effects.presetMenu.apply`) applies it (`lumetri.applyPreset`), and dragging it onto a +//! Timeline clip applies it to that clip (rows and grid cells alike). //! When the panel is wide (maximized), clicking a Lumetri Presets folder shows its presets as a //! thumbnail grid to the right of the tree. Automation ids: `effects.lumetriPreset.`, //! `effects.presetGrid` (the grid area), `effects.presetGrid.`. @@ -232,7 +233,7 @@ fn lumetri_rows(app: &mut FilmcraftApp, ui: &mut egui::Ui, filter: &str) -> Opti continue; } for p in items { - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click()); + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click_and_drag()); if resp.hovered() { ui.painter().rect_filled(r, 0.0, t.hover); } @@ -242,6 +243,9 @@ fn lumetri_rows(app: &mut FilmcraftApp, ui: &mut egui::Ui, filter: &str) -> Opti ui.painter().text(pos2(tr.max.x + 8.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::t(p.name), Tokens::ui(12.0), t.text); app.auto.add(&format!("effects.lumetriPreset.{}", p.name), r, p.name); let resp = resp.on_hover_text(crate::i18n::t(p.description)); + if resp.drag_started() { + crate::panels::start_drag_lumetri_preset(ui, p.name); + } if apply_menu(app, &resp) { apply = Some(p.name.to_string()); } @@ -273,12 +277,15 @@ fn preset_grid(app: &mut FilmcraftApp, ui: &mut egui::Ui, area: Rect) -> Option< } let pic = Rect::from_min_size(cell.min, vec2(cw, ch)); let tex = preset_texture(app, ui.ctx(), p, 160); - let resp = ui.interact(cell, egui::Id::new(("lumetri-grid", p.name)), Sense::click()).on_hover_text(crate::i18n::t(p.description)); + let resp = ui.interact(cell, egui::Id::new(("lumetri-grid", p.name)), Sense::click_and_drag()).on_hover_text(crate::i18n::t(p.description)); ui.painter().rect_filled(cell, 3.0, if resp.hovered() { t.hover } else { t.field_bg }); ui.painter().image(tex, pic.shrink(2.0), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); let label = ui.painter().layout(crate::i18n::t(p.name).to_string(), Tokens::ui(11.0), t.text, cw - 6.0); ui.painter().galley(pos2(cell.min.x + 4.0, pic.max.y + 2.0), label, t.text); app.auto.add(&format!("effects.presetGrid.{}", p.name), cell, p.name); + if resp.drag_started() { + crate::panels::start_drag_lumetri_preset(ui, p.name); + } if apply_menu(app, &resp) { apply = Some(p.name.to_string()); } diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index bcb386fb..95c9442e 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -145,6 +145,13 @@ pub fn dragged_template(ui: &egui::Ui) -> Option { pub fn start_drag_effect(ui: &egui::Ui, id: &str) { ui.ctx().data_mut(|d| d.insert_temp(payload_id(), Some(DragPayloadBox(DragPayload::Effect(id.to_string()))))); } +/// Prefix of a dragged effect payload that names a Lumetri preset rather than an effect id. +pub const LUMETRI_PRESET_PREFIX: &str = "lumetri:"; +/// A Lumetri preset (Effects ▸ Lumetri Presets) dragged onto a clip: an effect payload +/// `lumetri:` the Timeline applies with `lumetri.applyPreset`. +pub fn start_drag_lumetri_preset(ui: &egui::Ui, name: &str) { + start_drag_effect(ui, &format!("{LUMETRI_PRESET_PREFIX}{name}")); +} #[derive(Clone, Debug)] struct DragPayloadBox(DragPayload); @@ -185,9 +192,10 @@ pub fn drag_ghost(app: &FilmcraftApp, ui: &egui::Ui) { format!("{name} · {mode} · {:.2} s", s.range.duration.seconds()) } DragPayload::Template(_, name) => name.clone(), - DragPayload::Effect(e) => match e.strip_prefix("preset:") { - Some(name) => name.to_string(), - None => filmcraft_project::find_effect(e).map(|d| d.name.to_string()).unwrap_or_default(), + DragPayload::Effect(e) => match (e.strip_prefix("preset:"), e.strip_prefix(LUMETRI_PRESET_PREFIX)) { + (Some(name), _) => name.to_string(), + (_, Some(name)) => crate::i18n::t(name).to_string(), + _ => filmcraft_project::find_effect(e).map(|d| d.name.to_string()).unwrap_or_default(), }, }; let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("drag-ghost"))); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 17a5eef9..84b86397 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2471,6 +2471,8 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & if ctx.input(|i| i.pointer.any_released()) { let r = if let Some(name) = effect.strip_prefix("preset:") { app.session.execute("presets.apply", json!({"preset": name, "clips": [clip.0]})) + } else if let Some(name) = effect.strip_prefix(crate::panels::LUMETRI_PRESET_PREFIX) { + app.session.execute("lumetri.applyPreset", json!({"name": name, "clips": [clip.0]})) } else if transition_kind.is_some() { app.session.execute("effects.apply", params) } else { diff --git a/crates/ui-egui/tests/color_ui.rs b/crates/ui-egui/tests/color_ui.rs index 956e7f21..1ea047c8 100644 --- a/crates/ui-egui/tests/color_ui.rs +++ b/crates/ui-egui/tests/color_ui.rs @@ -324,3 +324,26 @@ fn lumetri_presets_in_the_effects_panel() { let e = lumetri(&mut d, clip); assert_eq!(e["params"]["temperature"]["value"], json!("Float(35.0)"), "{e}"); } + +#[test] +fn lumetri_presets_drag_onto_a_timeline_clip() { + let mut d = Driver::demo(); + d.ok("ui.panel.show", json!({"panel": "Effects"})); + d.frames(3); + // the first clip is selected; the preset is dropped on the second, which gets it alone + let selected = select_first_clip(&mut d); + let seq = d.exec("sequence.inspect", json!({})); + let target = seq["video"][0]["items"][1]["clip"].as_u64().unwrap_or_else(|| panic!("no second clip: {seq}")); + d.click("effects.folder.Lumetri Presets"); + d.click("effects.folder.Lumetri Presets/Monochrome"); + d.frames(2); + assert!(d.element(&format!("timeline.clip.{target}")).is_some(), "target clip not on screen"); + d.ok("ui.drag", json!({"from": {"id": "effects.lumetriPreset.Neutral Mono"}, "to": {"id": format!("timeline.clip.{target}")}, "steps": 8})); + d.frames(3); + let e = lumetri(&mut d, target); + assert_eq!(e["params"]["saturation"]["value"], json!("Float(0.0)"), "dropped preset on the target clip: {e}"); + assert_eq!(lumetri(&mut d, selected), Value::Null, "the selected clip it was not dropped on is untouched"); + // one undo step removes it again + d.exec("edit.undo", json!({})); + assert_eq!(lumetri(&mut d, target), Value::Null); +} From cca7d5ef0ea32ce269590356feda3e28e94e7518 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 21:23:42 +0200 Subject: [PATCH 102/144] #483: media dropped past the last track goes on a new track Problem: dropping a project item in the empty space above the top video track or below the last audio track did nothing: the Timeline only accepted drops on an existing track row, and timeline.place could only target existing tracks. Fix: timeline.place accepts "new" as `track` / `audioTrack`. It adds a track after the last one of its kind for each stream the item carries, then places the clip there, as one undo step (the new tracks are removed again if the placement fails). The Timeline shows the drop ghost in the empty space and places on a new video track (above) or audio track (below); the other stream goes on the track of the same number, or on a new one when that track does not exist. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands.rs | 5 +- crates/engine/src/sequence_tools.rs | 48 +++++++++++++ crates/engine/src/sequence_tools_tests.rs | 32 +++++++++ crates/ui-egui/src/panels/timeline.rs | 84 +++++++++++++++++++++-- 4 files changed, 161 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 33e2ee85..d49361f0 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -2110,9 +2110,12 @@ fn build() -> Vec { "Place Clip", [], None, - r#"{"item":id,"track":"V1"|id|"A1" (sound only)?,"audioTrack":"A1"|id?,"time":ticks|"frame":i64|"seconds":f64,"insert":bool,"sourceIn":ticks?,"duration":ticks?,"video":bool=true,"audio":bool=true}"#, + r#"{"item":id,"track":"V1"|id|"A1" (sound only)|"new" (a video track after the last)?,"audioTrack":"A1"|id|"new"?,"time":ticks|"frame":i64|"seconds":f64,"insert":bool,"sourceIn":ticks?,"duration":ticks?,"video":bool=true,"audio":bool=true}"#, has_seq, |s, p| { + if crate::sequence_tools::wants_new_track(p) { + return crate::sequence_tools::place_on_new_tracks(s, p); + } let stream_flag = |key: &str| match p.get(key) { None => Ok(true), Some(Value::Bool(v)) => Ok(*v), diff --git a/crates/engine/src/sequence_tools.rs b/crates/engine/src/sequence_tools.rs index a0027913..dc6c5795 100644 --- a/crates/engine/src/sequence_tools.rs +++ b/crates/engine/src/sequence_tools.rs @@ -723,6 +723,54 @@ pub(crate) fn add_tracks(s: &mut Session, p: &Value) -> Result { Ok(json!({"video": video, "audio": audio, "submix": submix})) } +/// `timeline.place` names `"new"` as its `track` or `audioTrack`. +pub(crate) fn wants_new_track(p: &Value) -> bool { + ["track", "audioTrack"].iter().any(|k| p.get(*k).and_then(Value::as_str).is_some_and(|v| v.eq_ignore_ascii_case("new"))) +} + +/// `timeline.place` with `"track":"new"` and/or `"audioTrack":"new"` (#483: media dropped in the +/// empty space above the video tracks or below the audio tracks): a track is added after the +/// last one of its kind for each stream the item carries (and the call enables), then the clip +/// goes on it. One undo step; when the placement fails, the new tracks go away again. +pub(crate) fn place_on_new_tracks(s: &mut Session, p: &Value) -> Result { + let cmd = "timeline.place"; + let item = crate::commands::item_p(p, "item").ok_or_else(|| bad(cmd, "need `item`"))?; + let pi = s.project.item(item).ok_or_else(|| bad(cmd, "no such item"))?; + let is_new = |k: &str| p.get(k).and_then(Value::as_str).is_some_and(|v| v.eq_ignore_ascii_case("new")); + let enabled = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(true); + let new_v = is_new("track") && pi.has_video() && enabled("video"); + let new_a = is_new("audioTrack") && pi.has_audio() && enabled("audio"); + if !new_v && !new_a { + return Err(bad(cmd, "no new track to add: the item has no picture or sound for it")); + } + let added = add_tracks(s, &json!({"video": u64::from(new_v), "audio": u64::from(new_a)}))?; + let mark = s.history.undo.len(); + let mut q = p.clone(); + for (key, kind) in [("track", "video"), ("audioTrack", "audio")] { + if is_new(key) { + q[key] = added.get(kind).and_then(|ids| ids.get(0)).cloned().unwrap_or(Value::Null); + } + } + let place = crate::commands::find(cmd).ok_or_else(|| EngineError::UnknownCommand(cmd.into()))?.run; + match place(s, &q) { + Ok(v) => { + // the new tracks and the clip are one undo step + if mark >= 1 + && s.history.undo.len() == mark + 1 + && let (Some((label, _)), Some((_, before))) = (s.history.undo.pop(), s.history.undo.pop()) + { + s.history.undo.push((label, before)); + } + Ok(v) + } + Err(e) => { + s.undo(); + s.history.redo.pop(); + Err(e) + } + } +} + fn delete_tracks(s: &mut Session, p: &Value) -> Result { let seq_id = s.state.active_sequence.ok_or(EngineError::NoSequence)?; let q = s.active_sequence().ok_or(EngineError::NoSequence)?; diff --git a/crates/engine/src/sequence_tools_tests.rs b/crates/engine/src/sequence_tools_tests.rs index 9e164ce2..d275f9b3 100644 --- a/crates/engine/src/sequence_tools_tests.rs +++ b/crates/engine/src/sequence_tools_tests.rs @@ -575,3 +575,35 @@ fn go_to_in_out_without_marks_go_to_the_sequence_ends() { s.execute("markers.goToIn", json!({})).unwrap(); assert_eq!(s.playhead(), s.active_sequence().unwrap().mark_in.unwrap()); } + +/// #483: media dropped in the empty space past the last track goes on a new track (with +/// `"new"`), one undo step; a placement that fails leaves no new track behind. +#[test] +fn place_on_new_tracks_adds_them_in_one_undo_step() { + let (mut s, item) = fresh(); + let before = s.active_sequence().unwrap().clone(); + let (nv, na) = (before.video_tracks.len(), before.audio_tracks.len()); + let undo = s.history.undo.len(); + let r = s.execute("timeline.place", json!({"item": item, "track": "new", "audioTrack": "new", "time": 0})).unwrap(); + let q = s.active_sequence().unwrap(); + assert_eq!((q.video_tracks.len(), q.audio_tracks.len()), (nv + 1, na + 1)); + let clips: Vec = r["clips"].as_array().unwrap().iter().map(|c| ClipId(c.as_u64().unwrap())).collect(); + assert_eq!(clips.len(), 2); + assert_eq!(q.video_tracks[nv].items.iter().map(|i| i.id).collect::>(), [clips[0]], "the picture is on the new top video track"); + assert_eq!(q.audio_tracks[na].items.iter().map(|i| i.id).collect::>(), [clips[1]], "the sound is on the new last audio track"); + assert_eq!(s.history.undo.len(), undo + 1, "one undo step"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(*s.active_sequence().unwrap(), before); + + // only the picture gets a new track; the sound follows the given track + s.execute("timeline.place", json!({"item": item, "track": "new", "audioTrack": "A1", "time": 0})).unwrap(); + let q = s.active_sequence().unwrap(); + assert_eq!((q.video_tracks.len(), q.audio_tracks.len()), (nv + 1, na)); + assert_eq!((q.video_tracks[nv].items.len(), q.audio_tracks[0].items.len()), (1, 1)); + s.execute("edit.undo", json!({})).unwrap(); + + // a placement that fails after the tracks were added: nothing changes + assert!(s.execute("timeline.place", json!({"item": item, "track": "new", "audioTrack": "A99", "time": 0})).is_err()); + assert_eq!(*s.active_sequence().unwrap(), before); + assert_eq!(s.history.undo.len(), undo); +} diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 17a5eef9..2b1e8fef 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -189,6 +189,26 @@ impl Layout { } } +/// #483: where a drop at `y` adds a track: the empty space above the top video track (a video +/// track) or below the last audio track (an audio track), never over a track, the caption tracks +/// or the Mix row. Gives the kind and the band the new track would take (`vh` / `ah` high). +fn new_track_at(seq: &Sequence, layout: &Layout, y: f32, vh: f32, ah: f32) -> Option<(TrackKind, Rect)> { + if layout.row_at(y).is_some() { + return None; + } + let cap_n = seq.caption_tracks.len(); + let tracks_top = layout.content.min.y + if cap_n > 0 { cap_n as f32 * super::timeline_captions::ROW_H + DIVIDER_H } else { 0.0 }; + let band = |y0: f32, y1: f32| Rect::from_min_max(pos2(layout.content.min.x, y0), pos2(layout.content.max.x, y1)); + if y < layout.split_y { + let top = layout.rows.iter().filter(|r| r.kind == TrackKind::Video).map(|r| r.rect.min.y).fold(layout.split_y, f32::min); + (y >= tracks_top && y < top).then(|| (TrackKind::Video, band((top - vh).max(tracks_top), top))) + } else { + let bottom = layout.rows.iter().filter(|r| r.kind == TrackKind::Audio).map(|r| r.rect.max.y).fold(layout.split_y + DIVIDER_H, f32::max); + let mix = layout.content.max.y - MASTER_H; + (y >= bottom && y < mix).then(|| (TrackKind::Audio, band(bottom, (bottom + ah).min(mix)))) + } +} + /// How far the Timeline reaches, in seconds: ten minutes past the end of the sequence. Premiere /// Pro's Timeline does the same (on a 6 s sequence its view stops with 00:10:06 at the right /// edge). The view cannot be scrolled past it and the scroll bar spans it. @@ -2380,21 +2400,34 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & let dur = source .map(|s| s.range.duration) .unwrap_or_else(|| app.session.project.item(item).map(|i| i.duration()).filter(|d| d.0 > 0 && !is_still).unwrap_or(still)); - if let Some(row) = &row { - let r = Rect::from_min_max(pos2(layout.x_of(t), row.rect.min.y + 1.0), pos2(layout.x_of(t + dur), row.rect.max.y - 1.0)); + // #483: in the empty space above the top video track or below the last audio track, a new track + let new_track = if row.is_some() { None } else { new_track_at(seq, layout, p.y, app.ui.timeline.video_track_h, app.ui.timeline.audio_track_h) }.filter( + |(kind, _)| { + let carries = app.session.project.item(item).is_some_and(|i| if *kind == TrackKind::Video { i.has_video() } else { i.has_audio() }); + carries && source.is_none_or(|s| if *kind == TrackKind::Video { s.video } else { s.audio }) + }, + ); + if let Some(band) = row.as_ref().map(|r| r.rect).or(new_track.map(|(_, band)| band)) { + let r = Rect::from_min_max(pos2(layout.x_of(t), band.min.y + 1.0), pos2(layout.x_of(t + dur), band.max.y - 1.0)); ui.painter().rect_filled(r, 3.0, Color32::from_white_alpha(40)); ui.painter().rect_stroke(r, 3.0, Stroke::new(1.5, Color32::WHITE), StrokeKind::Inside); if mods.command { ui.painter().text(r.left_top() + vec2(4.0, -2.0), Align2::LEFT_BOTTOM, tl!("Insert"), Tokens::ui(10.0), Color32::WHITE); } } + let target = match (&row, new_track) { + (Some(row), _) => Some(match row.kind { + TrackKind::Video => (json!(row.track.0), json!(seq.audio_tracks.get(row.index).or(seq.audio_tracks.first()).map(|t| t.id.0))), + TrackKind::Audio => (json!(seq.video_tracks.get(row.index).or(seq.video_tracks.first()).map(|t| t.id.0)), json!(row.track.0)), + }), + // the other stream goes on the track of the same number, or on a new one as well + (None, Some((TrackKind::Video, _))) => Some((json!("new"), seq.audio_tracks.get(seq.video_tracks.len()).map_or(json!("new"), |t| json!(t.id.0)))), + (None, Some((TrackKind::Audio, _))) => Some((seq.video_tracks.get(seq.audio_tracks.len()).map_or(json!("new"), |t| json!(t.id.0)), json!("new"))), + (None, None) => None, + }; if ctx.input(|i| i.pointer.any_released()) - && let Some(row) = row + && let Some((vt, at)) = target { - let (vt, at) = match row.kind { - TrackKind::Video => (Some(row.track.0), seq.audio_tracks.get(row.index).or(seq.audio_tracks.first()).map(|t| t.id.0)), - TrackKind::Audio => (seq.video_tracks.get(row.index).or(seq.video_tracks.first()).map(|t| t.id.0), Some(row.track.0)), - }; let mut params = json!({"item": item.0, "track": vt, "audioTrack": at, "time": t.0, "insert": mods.command}); if let Some(source) = source { params["sourceIn"] = json!(source.range.start.0); @@ -2648,3 +2681,40 @@ mod thumbnail_tiles_tests { assert!(thumbnail_tiles(ThumbnailMode::Continuous, 0.0, 0.0, 80.0, (0.0, 800.0), dur, FRAME).is_empty()); } } + +#[cfg(test)] +mod new_track_tests { + use super::*; + use filmcraft_project::{Project, SequenceSettings}; + + /// #483: a drop above the top video track adds a video track, one below the last audio track an + /// audio track; over a track, between the areas or on the Mix row it adds none. + #[test] + fn empty_space_past_the_tracks_adds_a_track() { + let mut p = Project::new("t"); + let id = p.new_sequence("s", SequenceSettings::default(), 1, 1, None); + let seq = p.sequence(id).cloned().expect("sequence"); + // video area 0..200 (V1 at 150..200), divider, audio area 205..466 (A1 at 205..255), Mix 466..500 + let row = |kind, track: &filmcraft_project::Track, y0: f32| Row { + track: track.id, + kind, + index: 0, + rect: Rect::from_min_max(pos2(0.0, y0), pos2(800.0, y0 + 50.0)), + lane: false, + }; + let layout = Layout { + content: Rect::from_min_max(pos2(0.0, 0.0), pos2(800.0, 500.0)), + ruler: Rect::from_min_max(pos2(0.0, -20.0), pos2(800.0, 0.0)), + rows: vec![row(TrackKind::Video, &seq.video_tracks[0], 150.0), row(TrackKind::Audio, &seq.audio_tracks[0], 205.0)], + pps: 100.0, + scroll: 0.0, + split_y: 200.0, + }; + let at = |y| new_track_at(&seq, &layout, y, 50.0, 50.0); + assert_eq!(at(40.0), Some((TrackKind::Video, Rect::from_min_max(pos2(0.0, 100.0), pos2(800.0, 150.0))))); + assert_eq!(at(300.0), Some((TrackKind::Audio, Rect::from_min_max(pos2(0.0, 255.0), pos2(800.0, 305.0))))); + for y in [160.0, 202.0, 230.0, 480.0] { + assert_eq!(at(y), None, "y = {y}"); + } + } +} From 334bccde88c5d428931b987a4622b53f1db1e113 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 15:23:52 -0400 Subject: [PATCH 103/144] Revert "Merge pull request #674 from vbasky/fix/542-limiter-lookahead" This reverts commit 62a0eea51958727812c8dd2d8a14f3e81a49f860, reversing changes made to 23ac3447985fccf003be076d32b5aaccb2106999. --- crates/audio-dsp/src/effects/dynamics.rs | 44 +++--------------------- crates/render/src/audio_fx.rs | 3 +- crates/render/src/tests.rs | 10 ------ 3 files changed, 5 insertions(+), 52 deletions(-) diff --git a/crates/audio-dsp/src/effects/dynamics.rs b/crates/audio-dsp/src/effects/dynamics.rs index 40dec64c..5807d1ba 100644 --- a/crates/audio-dsp/src/effects/dynamics.rs +++ b/crates/audio-dsp/src/effects/dynamics.rs @@ -263,8 +263,6 @@ pub struct Limiter { true_peak: bool, /// Look-ahead (samples) and detector alignment delay. look: usize, - /// Delay-line capacity (samples of look-ahead) allocated at construction for 0…30 ms. - max_look: usize, det_delay: usize, bank: PolyphaseBank, interp: Vec, @@ -287,21 +285,17 @@ impl Limiter { ParamSpec::new("input_gain", "Input Boost", 0.0, 24.0, 0.0, Unit::Decibels), ParamSpec::new("ceiling", "Ceiling", -24.0, 0.0, -1.0, Unit::Decibels), ParamSpec::log("release", "Release", 1.0, 1000.0, 60.0, Unit::Milliseconds), - ParamSpec::new("lookahead", "Look-Ahead", 0.0, 30.0, 5.0, Unit::Milliseconds), ParamSpec::toggle("true_peak", "True Peak", true), ]; - /// Default look-ahead when the parameter is left at its DSP default. + /// Fixed look-ahead time. pub const LOOKAHEAD_MS: f32 = 5.0; - /// Longest look-ahead the delay line is sized for (Hard Limiter's 0…30 ms range). - pub const MAX_LOOKAHEAD_MS: f32 = 30.0; pub fn new(sample_rate: f32, channels: usize) -> Self { let channels = channels.max(1); let bank = PolyphaseBank::new(true_peak_factor(sample_rate as f64).max(2)); - let max_look = ((Self::MAX_LOOKAHEAD_MS * 0.001 * sample_rate).round() as usize).max(1); - let look = ((Self::LOOKAHEAD_MS * 0.001 * sample_rate).round() as usize).max(1).min(max_look); + let look = ((Self::LOOKAHEAD_MS * 0.001 * sample_rate).round() as usize).max(1); let det_delay = bank.delay(); - let dlen = max_look + det_delay + 1; + let dlen = look + det_delay + 1; let mut s = Limiter { pv: ParamValues::new(Self::PARAMS), sr: sample_rate, @@ -310,7 +304,6 @@ impl Limiter { rel: 0.0, true_peak: true, look, - max_look, det_delay, bank, interp: vec![Interpolator::default(); channels], @@ -333,15 +326,6 @@ impl Limiter { self.ceiling = db_to_gain(self.pv.v("ceiling")); self.rel = coef(self.pv.v("release"), self.sr); self.true_peak = self.pv.on("true_peak"); - let look = ((self.pv.v("lookahead") * 0.001 * self.sr).round() as usize).max(1).min(self.max_look); - if look != self.look { - self.look = look; - self.minq.clear(); - self.box_ring = vec![1.0; look + 1]; - self.box_pos = 0; - self.box_sum = (look + 1) as f64; - self.rel_state = 1.0; - } if snap { self.in_gain.snap(); } @@ -379,7 +363,7 @@ impl AudioEffect for Limiter { for i in 0..n { let g_in = self.in_gain.tick(); let det_pos = (self.wpos + dlen - self.det_delay) % dlen; - let out_pos = (self.wpos + dlen - (self.look + self.det_delay)) % dlen; + let out_pos = (self.wpos + 1) % dlen; // oldest = look + det_delay samples ago let mut det = 0.0f32; for ch in 0..nch { let x = channels[ch][i] * g_in; @@ -571,24 +555,4 @@ mod tests { let peak = tail.iter().fold(0.0f32, |a, v| a.max(v.abs())); assert!((lin_to_db(peak) + 6.0).abs() < 0.3, "{}", lin_to_db(peak)); } - - #[test] - fn limiter_lookahead_changes_latency_and_transient_shape() { - let mut a = Limiter::new(SR, 1); - let mut b = Limiter::new(SR, 1); - a.set_param("lookahead", 0.0); - b.set_param("lookahead", 30.0); - a.set_param("ceiling", -6.0); - b.set_param("ceiling", -6.0); - assert!(b.latency() > a.latency(), "{} vs {}", b.latency(), a.latency()); - let mut xa: Vec = (0..4800).map(|i| 0.08 * (2.0 * std::f32::consts::PI * 997.0 * i as f32 / SR).sin()).collect(); - for s in &mut xa[1200..1248] { - *s = 0.9; - } - let mut xb = xa.clone(); - a.process(&mut [&mut xa]); - b.process(&mut [&mut xb]); - let max = xa.iter().zip(&xb).map(|(x, y)| (x - y).abs()).fold(0.0f32, f32::max); - assert!(max > 1e-4, "look-ahead of 0 ms and 30 ms produced the same waveform (max Δ {max})"); - } } diff --git a/crates/render/src/audio_fx.rs b/crates/render/src/audio_fx.rs index adf9d35e..db84a1f6 100644 --- a/crates/render/src/audio_fx.rs +++ b/crates/render/src/audio_fx.rs @@ -44,12 +44,11 @@ pub(crate) fn mapping(id: &str) -> Option { }, "hard_limiter" => Mapping { dsp: "limiter", - preroll: |e, t| (f(e, "lookahead", t) as f64 / 1000.0 + f(e, "release", t) as f64 / 1000.0 * 5.0).clamp(0.05, 2.0), + preroll: |e, t| (f(e, "release", t) as f64 / 1000.0 * 5.0).clamp(0.05, 2.0), apply: |d, e, t| { d.set_param("ceiling", f(e, "max", t)); d.set_param("input_gain", f(e, "boost", t).max(0.0)); d.set_param("release", f(e, "release", t)); - d.set_param("lookahead", f(e, "lookahead", t)); }, }, "delay" => Mapping { diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index ecb02d9d..31f5edfc 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -301,16 +301,6 @@ fn audio_fx_limiter_holds_ceiling() { assert!(peak <= ceiling * 1.01 && peak > ceiling * 0.7, "peak {peak} vs ceiling {ceiling}"); } -#[test] -fn audio_fx_limiter_lookahead_changes_the_mix() { - let (p0, seq0, map0) = tone_with(&[("amplify", &[("gain", 30.0)]), ("hard_limiter", &[("max", -6.0), ("lookahead", 0.0)])]); - let (p1, seq1, map1) = tone_with(&[("amplify", &[("gain", 30.0)]), ("hard_limiter", &[("max", -6.0), ("lookahead", 30.0)])]); - let a = mix(&p0, seq0, &map0, 0, 48_000); - let b = mix(&p1, seq1, &map1, 0, 48_000); - let max = a.iter().zip(&b).map(|(x, y)| (x - y).abs()).fold(0f32, f32::max); - assert!(max > 1e-4, "look-ahead of 0 ms and 30 ms mixed the same clip"); -} - #[test] fn audio_fx_chain_is_continuous_and_random_access_matches() { let (p, seq, map) = tone_with(&[("delay", &[("delay", 0.05), ("feedback", 50.0), ("mix", 50.0)])]); From 4cdb15466e4acb2ee81c91d3072ad7ad992c91a6 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 15:24:43 -0400 Subject: [PATCH 104/144] Captions: a TTML frame count that saturates the clock is rejected, not read as the end of time (#705 follow-up) Co-Authored-By: Claude Opus 5.5 --- crates/captions/src/ttml.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/captions/src/ttml.rs b/crates/captions/src/ttml.rs index 1987ba32..6c98a7ff 100644 --- a/crates/captions/src/ttml.rs +++ b/crates/captions/src/ttml.rs @@ -287,8 +287,12 @@ fn parse_time(s: &str, tm: Timing) -> Option { "f" => { let n: i128 = vi.parse().ok()?; let fd = tm.rate.tick_of(1).0 as i128; - let whole = tm.rate.tick_of(n as i64).0 as i128; - whole + decimal_ticks("0", vf, fd)? + let whole = tm.rate.tick_of(i64::try_from(n).ok()?); + // tick_of saturates; a saturated count overflowed rather than named a real time + if whole == Tick(i64::MAX) { + return None; + } + whole.0 as i128 + decimal_ticks("0", vf, fd)? } "t" => { let n: i128 = vi.parse().ok()?; From b43e3828a9d6ef7d3c93ed84f5ce5b2c9c18bc3a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 15:24:43 -0400 Subject: [PATCH 105/144] Revert "Merge pull request #685 from vbasky/fix/linked-clip-delete" This reverts commit 6145dcc568f8a06a9d113ef5137b2b1114b3cb92, reversing changes made to f34a00c0208a04d34958250e5a4951fa272b099f. --- crates/engine/src/commands.rs | 8 +++--- crates/engine/src/tests.rs | 39 --------------------------- crates/ui-egui/src/panels/timeline.rs | 11 +++++--- crates/ui-egui/tests/keyboard_ui.rs | 25 ----------------- 4 files changed, 12 insertions(+), 71 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index df6c80f4..c1f2f56d 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1257,7 +1257,7 @@ fn build() -> Vec { let caps = s.state.caption_selection.clone(); return crate::captions::delete(s, &caps, false); } - let sel = if p.get("clips").is_some() || p.get("clip").is_some() { with_links(s, &clips_p(s, p)) } else { s.state.selection.clone() }; + let sel = with_links(s, &clips_p(s, p)); s.edit_sequence("Clear", |q, _, st| { edit::delete_items(q, &sel); st.selection.clear(); @@ -1270,7 +1270,7 @@ fn build() -> Vec { let caps = s.state.caption_selection.clone(); return crate::captions::delete(s, &caps, true); } - let sel = if p.get("clips").is_some() || p.get("clip").is_some() { with_links(s, &clips_p(s, p)) } else { s.state.selection.clone() }; + let sel = with_links(s, &clips_p(s, p)); s.edit_sequence("Ripple Delete", |q, _, st| { let spans = edit::ripple_delete_items(q, &sel)?; if st.ripple_sequence_markers { @@ -2163,13 +2163,13 @@ fn build() -> Vec { Ok(json!({"clips": ids.iter().map(|c| c.0).collect::>()})) } ), - cmd!("timeline.select", "Select Clips", [], None, r#"{"clips":[id],"transitions":[id]?,"add":bool,"toggle":bool,"linked":bool?}"#, has_seq, |s, p| { + cmd!("timeline.select", "Select Clips", [], None, r#"{"clips":[id],"transitions":[id]?,"add":bool,"toggle":bool}"#, has_seq, |s, p| { if let Some(ids) = p.get("transitions") { return select_transitions(s, ids, bool_p(p, "add").unwrap_or(false), bool_p(p, "toggle").unwrap_or(false)); } let clips: Vec = p.get("clips").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_u64().map(ClipId)).collect()).unwrap_or_default(); - let clips = if bool_p(p, "linked").unwrap_or(true) { with_links(s, &clips) } else { clips }; + let clips = with_links(s, &clips); // selecting clips leaves trim mode (Premiere: clip and edit point selections are exclusive) s.state.edit_points.clear(); s.state.trim_shift = Default::default(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f2b50dd8..9852734d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -139,45 +139,6 @@ fn trim_linked_and_ripple_delete() { assert_eq!(q.video_tracks[0].items[0].start, s.sequence_rate().tick_of(12), "the trim gap remains"); } -#[test] -fn clear_respects_individually_selected_linked_clips_and_undo() { - for audio in [false, true] { - let mut s = demo(); - let video = s.active_sequence().unwrap().video_tracks[0].items[0].clone(); - let sound = s.active_sequence().unwrap().audio_tracks[0].items.iter().find(|i| i.link == video.link).unwrap().clone(); - let (selected, partner) = if audio { (sound.id, video.id) } else { (video.id, sound.id) }; - s.execute("timeline.select", json!({"clips": [selected.0], "linked": false})).unwrap(); - assert_eq!(s.state.selection, vec![selected]); - s.execute("edit.clear", json!({})).unwrap(); - assert!(s.active_sequence().unwrap().find_item(selected).is_none()); - assert!(s.active_sequence().unwrap().find_item(partner).is_some()); - s.execute("edit.undo", json!({})).unwrap(); - assert!(s.active_sequence().unwrap().find_item(selected).is_some()); - s.execute("timeline.select", json!({"clips": [selected.0]})).unwrap(); - assert_eq!(s.state.selection.len(), 2); - s.execute("edit.clear", json!({})).unwrap(); - assert!(s.active_sequence().unwrap().find_item(partner).is_none()); - } -} - -#[test] -fn ripple_delete_respects_individual_selection_and_explicit_clear_keeps_link_behavior() { - let mut s = demo(); - let video = s.active_sequence().unwrap().video_tracks[0].items[0].clone(); - let sound = s.active_sequence().unwrap().audio_tracks[0].items.iter().find(|i| i.link == video.link).unwrap().id; - let tracks: Vec<_> = s.active_sequence().unwrap().all_tracks().map(|t| t.id.0).collect(); - for track in tracks { - s.execute("timeline.setTrack", json!({"track": track, "syncLock": false})).unwrap(); - } - s.execute("timeline.select", json!({"clips": [video.id.0], "linked": false})).unwrap(); - s.execute("edit.rippleDelete", json!({})).unwrap(); - assert!(s.active_sequence().unwrap().find_item(video.id).is_none()); - assert!(s.active_sequence().unwrap().find_item(sound).is_some()); - s.execute("edit.undo", json!({})).unwrap(); - s.execute("edit.clear", json!({"clips": [video.id.0]})).unwrap(); - assert!(s.active_sequence().unwrap().find_item(sound).is_none()); -} - #[test] fn slide_moves_linked_audio_with_the_video() { let mut s = demo(); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 17a5eef9..e270db5f 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2046,9 +2046,14 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & // select (shift toggles; alt selects one side of a link) let sel = &app.session.state.selection; if mods.shift { - let _ = app.session.execute("timeline.select", json!({"clips": [clip.0], "toggle": true, "linked": !mods.alt})); - } else if mods.alt || !sel.contains(&clip) { - let _ = app.session.execute("timeline.select", json!({"clips": [clip.0], "linked": !mods.alt})); + let _ = app.session.execute("timeline.select", json!({"clips": [clip.0], "toggle": true})); + } else if !sel.contains(&clip) { + if mods.alt { + app.session.state.selection = vec![clip]; + app.session.state.transition_selection.clear(); + } else { + let _ = app.session.execute("timeline.select", json!({"clips": [clip.0]})); + } } if resp.drag_started() { let clips = app.session.state.selection.clone(); diff --git a/crates/ui-egui/tests/keyboard_ui.rs b/crates/ui-egui/tests/keyboard_ui.rs index 930ae086..52ece399 100644 --- a/crates/ui-egui/tests/keyboard_ui.rs +++ b/crates/ui-egui/tests/keyboard_ui.rs @@ -71,31 +71,6 @@ impl Driver { } } -#[test] -fn alt_click_isolates_already_selected_linked_clip_before_delete() { - for audio in [false, true] { - let mut d = Driver::new(); - let video = d.app().session.active_sequence().unwrap().video_tracks[0].items[0].clone(); - let sound = d.app().session.active_sequence().unwrap().audio_tracks[0].items.iter().find(|i| i.link == video.link).unwrap().clone(); - let (selected, partner) = if audio { (sound.id, video.id) } else { (video.id, sound.id) }; - d.exec("timeline.select", json!({"clips": [selected.0]})); - assert_eq!(d.app().session.state.selection.len(), 2); - let r = d.app().auto.find(&format!("timeline.clip.{}", selected.0)).unwrap().rect; - d.harness.input_mut().events.push(egui::Event::ModifiersChanged(egui::Modifiers::ALT)); - d.frames(1); - d.ok("ui.click", json!({"x": r[0] + r[2] * 0.5, "y": r[1] + r[3] * 0.3, "modifiers": {"alt": true}})); - d.frames(3); - assert_eq!(d.app().session.state.selection, vec![selected]); - d.harness.input_mut().events.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); - d.frames(1); - d.key("Delete"); - assert!(d.app().session.active_sequence().unwrap().find_item(selected).is_none()); - assert!(d.app().session.active_sequence().unwrap().find_item(partner).is_some()); - d.exec("edit.undo", json!({})); - assert!(d.app().session.active_sequence().unwrap().find_item(selected).is_some()); - } -} - /// 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(); From e78223db6d1c7ff0bb30c96594f4b7642493212a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 15:24:44 -0400 Subject: [PATCH 106/144] Revert "Merge pull request #662 from MrCloudy2/fix/export-hevc-stall-480" This reverts commit aa8ad402cc4e38509b1a30218741b995a893aca1, reversing changes made to 5f9b5e123498007a069d9b724fa051e45f5fa1e3. --- Cargo.lock | 1 - crates/codecs/Cargo.toml | 1 - crates/codecs/src/gop.rs | 161 +++---------------------------------- crates/media/src/cancel.rs | 54 ------------- 4 files changed, 9 insertions(+), 208 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 68dc628b..27b7f514 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1829,7 +1829,6 @@ dependencies = [ "filmcraft-vp9", "image", "log", - "rayon", "serde_json", "symphonia", "thiserror 2.0.21", diff --git a/crates/codecs/Cargo.toml b/crates/codecs/Cargo.toml index 7caf5eb9..3a490e2c 100644 --- a/crates/codecs/Cargo.toml +++ b/crates/codecs/Cargo.toml @@ -31,7 +31,6 @@ filmcraft-av1 = { workspace = true } filmcraft-apv = { workspace = true } filmcraft-aac = { workspace = true } filmcraft-opus = { workspace = true } -rayon = { workspace = true } thiserror = { workspace = true } log = { workspace = true } # Decode timing counters (`gop_stats`); std's Instant panics on wasm32. diff --git a/crates/codecs/src/gop.rs b/crates/codecs/src/gop.rs index 4f442887..b23b130e 100644 --- a/crates/codecs/src/gop.rs +++ b/crates/codecs/src/gop.rs @@ -109,9 +109,8 @@ impl std::ops::Sub for GopStats { } } -/// A container's video sample table, in decode (file) order. `Sync`: a cache decodes on its own -/// threads ([`isolated`]). -pub trait VideoSamples: Sync { +/// A container's video sample table, in decode (file) order. +pub trait VideoSamples { fn count(&self) -> usize; /// Presentation timestamp of sample `i` (track units). fn pts(&self, i: usize) -> i64; @@ -426,57 +425,6 @@ const MIN_FRAMES: usize = 64; /// rather than restarting at the sync sample. const CONTINUE_THROUGH: usize = 48; -/// Threads that run the caches' decoding (see [`isolated`]); `None` where none can be made. -#[cfg(not(target_arch = "wasm32"))] -fn decode_pool() -> Option<&'static rayon::ThreadPool> { - static POOL: std::sync::OnceLock> = std::sync::OnceLock::new(); - POOL.get_or_init(|| { - let n = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1); - rayon::ThreadPoolBuilder::new().num_threads(n).thread_name(|i| format!("decode-{i}")).build().ok() - }) - .as_ref() -} - -/// Run a cache's decoding, its lock held by the caller, on [`decode_pool`], the caller waiting -/// without doing other rayon work meanwhile (#480). -/// -/// That decoding starts rayon work (a decoder's slices, cropping and rotating the frames). On the -/// pool of the render jobs that ask the cache for frames, a worker that took part of it could, -/// waiting inside it, pick up another of those jobs and block on the lock: the lock holder then -/// waits for its work on the blocked worker's stack, and that worker for the lock, for good (an -/// export of rotated iPhone clips stopped making frames). On the decode pool that work only ever -/// runs beside other caches' decoding, which never waits for a cache's lock. A caller that is a -/// rayon worker would also run other jobs of its pool while it waits for the decode pool, some -/// of them asking for a cache's lock while it holds this one: it waits on a plain thread instead. -fn isolated(f: impl FnOnce() -> crate::Result + Send) -> crate::Result { - let ctx = filmcraft_media::cancel::Context::current(); - let job = move || ctx.run(f); - #[cfg(not(target_arch = "wasm32"))] - if let Some(pool) = decode_pool() { - if rayon::current_thread_index().is_none() { - return pool.install(job); - } - let slot = Mutex::new(Some(job)); - let waited = std::thread::scope(|scope| { - let spawned = std::thread::Builder::new().name("decode-wait".into()).spawn_scoped(scope, || { - let job = slot.lock().unwrap_or_else(PoisonError::into_inner).take(); - job.map(|job| pool.install(job)) - }); - spawned.map(|h| h.join()) - }); - return match waited { - Ok(Ok(Some(r))) => r, - Ok(Err(_)) => Err(CodecError::Decode("internal error while decoding".into())), - // no thread to wait on: decode here, as before - Ok(Ok(None)) | Err(_) => match slot.into_inner().unwrap_or_else(PoisonError::into_inner) { - Some(job) => job(), - None => Err(CodecError::Decode("internal error while decoding".into())), - }, - }; - } - job() -} - impl GopCache { pub fn new(explicit_color: Option) -> Self { // unit tests count decoder restarts: each of their caches gets a pool of its own @@ -534,11 +482,7 @@ impl GopCache { } fn store(&self, st: &mut State, pts: i64, f: VideoFrame, draft: bool) { - self.store_finished(st, pts, self.finish(f), draft); - } - - /// Cache a frame [`Self::finish`] has been applied to. - fn store_finished(&self, st: &mut State, pts: i64, f: VideoFrame, draft: bool) { + let f = self.finish(f); st.last_used = Instant::now(); if draft { st.drafts.insert(pts); @@ -660,22 +604,6 @@ impl GopCache { if st.intra { return self.intra_frame(st, s, i, want_pts); } - // with the lock held: see `isolated` - let st = &mut *st; - isolated(|| self.decode_until(st, s, i, want_pts, late_before, draft)) - } - - /// Decode (the lock held) until the frame with `want_pts` (sample `i`) is cached; return it. - fn decode_until( - &self, - st: &mut State, - s: &dyn VideoSamples, - i: usize, - want_pts: i64, - late_before: Option, - draft: bool, - ) -> crate::Result> { - let n = s.count(); let mut key = s.sync_before(i); // Continue the running decoder when it has passed the wanted sample's sync sample and // either has not reached the sample yet or has been fed it without outputting it yet @@ -728,14 +656,14 @@ impl GopCache { let out = timed(|| decoder.decode(&data, s.pts(k)))?; st.next += 1; DECODED.fetch_add(1, Ordering::Relaxed); - self.store_output(st, out); + self.store_output(&mut st, out); if st.cached(want_pts, draft).is_some() { break; } } if st.cached(want_pts, draft).is_none() { let out = st.decoder.as_mut().map(|d| timed(|| d.flush())).unwrap_or_default(); - self.store_output(st, out); + self.store_output(&mut st, out); st.next = usize::MAX; } // nearest decoded frame at or before the wanted pts (robust to decoder pts quirks) @@ -820,17 +748,14 @@ impl GopCache { DECODED.fetch_add(1, Ordering::Relaxed); timed(|| dec.decode(&data, want_pts)) }); - // crop and rotate before taking the lock: that work runs on rayon (see `isolated`) - let res = res.map(|out| { - count_frames(&out); - out.into_iter().map(|d| (d.pts, self.finish(d.frame), d.draft)).collect::>() - }); let mut st = self.shared.state.lock().unwrap_or_else(|e| e.into_inner()); if st.spare.len() < 16 { st.spare.push(dec); } - for (pts, frame, draft) in res? { - self.store_finished(&mut st, pts, frame, draft); + let res = res?; + count_frames(&res); + for d in res { + self.store(&mut st, d.pts, d.frame, d.draft); } st.at_or_before(want_pts, true).ok_or_else(|| CodecError::Decode("frame not produced".into())) } @@ -962,74 +887,6 @@ mod tests { assert!(s.decodes.load(Ordering::Relaxed) <= 60 + 24 + 1); } - /// A decoder that, like a slice-threaded one, splits each picture's work over rayon. - struct ParDec(Box); - - impl VideoDecoder for ParDec { - fn decode(&mut self, sample: &[u8], pts: i64) -> crate::Result> { - use rayon::prelude::*; - let rows: u64 = - (0..64u32).into_par_iter().with_max_len(1).map(|r| (0..20_000u64).fold(u64::from(r), |a, x| a.wrapping_mul(31).wrapping_add(x))).sum(); - std::hint::black_box(rows); - self.0.decode(sample, pts) - } - fn flush(&mut self) -> Vec { - self.0.flush() - } - fn reset(&mut self) { - self.0.reset() - } - fn name(&self) -> &str { - "par" - } - } - - struct ParSamples(Samples); - - impl VideoSamples for ParSamples { - fn count(&self) -> usize { - self.0.count() - } - fn pts(&self, i: usize) -> i64 { - self.0.pts(i) - } - fn sync_before(&self, i: usize) -> usize { - self.0.sync_before(i) - } - fn sample_at(&self, t: i64) -> Option { - self.0.sample_at(t) - } - fn read(&self, i: usize) -> crate::Result> { - self.0.read(i) - } - fn make_decoder(&self) -> crate::Result> { - Ok(Box::new(ParDec(self.0.make_decoder()?))) - } - } - - /// #480: render jobs on rayon asking one source for frames while its decoding (a decoder's - /// slices, rotating the frames) runs on rayon too. A worker that took part of that work and, - /// waiting inside it, picked up another frame request blocked on the cache's lock for good: - /// an export of rotated iPhone clips stopped making frames. - #[test] - fn rayon_frame_requests_while_decoding_on_rayon_never_deadlock() { - use rayon::prelude::*; - let s = Arc::new(ParSamples(samples(240, 30, 4, false))); - let (tx, rx) = std::sync::mpsc::channel(); - std::thread::spawn(move || { - for round in 0..40usize { - // export-like: many frame workers, frames of several GOPs of a fresh source at once - let c = GopCache::new(None).with_rotation(1); - (0..96usize).into_par_iter().with_max_len(1).for_each(|k| { - let i = (k * 37 + round * 53) % 240; - assert_eq!(index_of(&c.frame(&*s, i as i64 * 1000).expect("frame")), i); - }); - } - let _ = tx.send(()); - }); - assert!(rx.recv_timeout(Duration::from_secs(120)).is_ok(), "frame requests deadlocked"); - } - #[test] fn running_decoder_continues_through_a_nearby_sync_sample() { let s = samples(200, 30, 0, false); diff --git a/crates/media/src/cancel.rs b/crates/media/src/cancel.rs index 5c2b5f28..bfb391dd 100644 --- a/crates/media/src/cancel.rs +++ b/crates/media/src/cancel.rs @@ -96,64 +96,10 @@ pub fn cancelled() -> bool { CURRENT.with(|c| c.borrow().as_ref().is_some_and(|f| f.load(Ordering::Relaxed))) } -/// The hints of the work running on this thread ([`with_cancel`], [`with_catch_up`], -/// [`with_draft`], [`with_background`]), to carry them to another thread doing part of that work. -#[derive(Clone, Default)] -pub struct Context { - cancel: Option>, - catch_up: Option, - draft: bool, - background: bool, -} - -impl Context { - /// This thread's hints. - pub fn current() -> Self { - Context { cancel: CURRENT.with(|c| c.borrow().clone()), catch_up: catch_up(), draft: draft(), background: background() } - } - - /// Run `f` with these hints, restoring this thread's however `f` ends (a pool thread must - /// not keep another job's hints after a panic). - pub fn run(self, f: impl FnOnce() -> R) -> R { - struct Restore(Context); - impl Drop for Restore { - fn drop(&mut self) { - let prev = std::mem::take(&mut self.0); - CURRENT.with(|c| *c.borrow_mut() = prev.cancel); - CATCH_UP.with(|c| c.set(prev.catch_up)); - DRAFT.with(|c| c.set(prev.draft)); - BACKGROUND.with(|c| c.set(prev.background)); - } - } - let _restore = Restore(Context::current()); - CURRENT.with(|c| *c.borrow_mut() = self.cancel); - CATCH_UP.with(|c| c.set(self.catch_up)); - DRAFT.with(|c| c.set(self.draft)); - BACKGROUND.with(|c| c.set(self.background)); - f() - } -} - #[cfg(test)] mod tests { use super::*; - #[test] - fn context_carries_the_hints_to_another_thread_and_restores_its_own() { - let flag = Arc::new(AtomicBool::new(true)); - let ctx = with_cancel(&flag, || with_draft(true, || with_background(true, || with_catch_up(Some(Tick(5)), Context::current)))); - std::thread::scope(|s| { - s.spawn(|| { - let seen = with_draft(false, || ctx.clone().run(|| (cancelled(), draft(), background(), catch_up()))); - assert_eq!(seen, (true, true, true, Some(Tick(5)))); - // a panic inside leaves this thread's own hints in place - let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| ctx.clone().run(|| panic!("job")))); - assert!(r.is_err()); - assert_eq!((cancelled(), draft(), background(), catch_up()), (false, false, false, None)); - }); - }); - } - #[test] fn flag_is_scoped_to_the_closure() { let flag = Arc::new(AtomicBool::new(false)); From 366537838412f98cb0ad1961c37e8b57fb058e5d Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 15:24:48 -0400 Subject: [PATCH 107/144] Revert "Merge pull request #689 from Ni-zav/fix/654-track-header-rename-s20261010T1632Z-b8e4" This reverts commit 826858dfecd41a3c89f9b5b9b5cb6f8b00647571, reversing changes made to 0a9c133b4e164679c251db16b187c30ae1e3ec62. --- crates/ui-egui/src/panels/timeline.rs | 32 --------------- crates/ui-egui/tests/timeline_rename_ui.rs | 46 ---------------------- 2 files changed, 78 deletions(-) delete mode 100644 crates/ui-egui/tests/timeline_rename_ui.rs diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index e270db5f..9afd8825 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1147,38 +1147,6 @@ fn draw_headers(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, rows: egui::Id::new(("hdr", r.track.0)), Sense::click(), ); - app.auto.add(&format!("timeline.track.{label}.name"), resp.rect, "Track name (right-click to rename)"); - let rename_id = egui::Id::new(("timeline-track-rename", r.track.0)); - if resp.secondary_clicked() { - // Start each menu opening from the committed name, not a cancelled draft. - ui.data_mut(|d| d.insert_temp(rename_id, tr.name.clone())); - } - egui::Popup::context_menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { - ui.set_min_width(220.0); - ui.label(tl!("Rename")); - let mut name: String = ui.data(|d| d.get_temp(rename_id)).unwrap_or_else(|| tr.name.clone()); - let input = ui.add(egui::TextEdit::singleline(&mut name).desired_width(210.0)); - app.auto.add(&format!("timeline.track.{label}.renameField"), input.rect, "New track name"); - if resp.secondary_clicked() { - input.request_focus(); - } - let save_on_enter = input.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); - let valid = !name.trim().is_empty(); - ui.horizontal(|ui| { - let apply = ui.add_enabled(valid, egui::Button::new(tl!("Apply"))); - app.auto.add(&format!("timeline.track.{label}.renameApply"), apply.rect, "Apply track name"); - if (apply.clicked() || (valid && save_on_enter)) && name.trim() != tr.name { - actions.push(("timeline.setTrack".into(), json!({"track": r.track.0, "name": name.trim()}))); - ui.close(); - } else if apply.clicked() || (valid && save_on_enter) { - ui.close(); - } - if ui.button(tl!("Cancel")).clicked() { - ui.close(); - } - }); - ui.data_mut(|d| d.insert_temp(rename_id, name)); - }); if resp.double_clicked() { let h = if r.kind == TrackKind::Video { &mut app.ui.timeline.video_track_h } else { &mut app.ui.timeline.audio_track_h }; *h = if *h < 50.0 { 64.0 } else { 30.0 }; diff --git a/crates/ui-egui/tests/timeline_rename_ui.rs b/crates/ui-egui/tests/timeline_rename_ui.rs deleted file mode 100644 index aa1ef4c9..00000000 --- a/crates/ui-egui/tests/timeline_rename_ui.rs +++ /dev/null @@ -1,46 +0,0 @@ -//! Right-click renaming a Timeline track uses timeline.setTrack, which is undoable. - -use egui_kittest::Harness; -use filmcraft_engine::Session; -use filmcraft_ui_egui::FilmcraftApp; -use serde_json::json; - -fn step(h: &mut Harness<'static, FilmcraftApp>) { - let ctx = h.ctx.clone(); - let mut raw = std::mem::take(h.input_mut()); - eframe::App::raw_input_hook(h.state_mut(), &ctx, &mut raw); - *h.input_mut() = raw; - ctx.request_repaint(); - h.step(); -} - -fn click(h: &mut Harness<'static, FilmcraftApp>, id: &str, button: egui::PointerButton) { - let [x, y, w, height] = h.state().auto.find(id).expect("visible control").rect; - let at = egui::pos2(x + w * 0.5, y + height * 0.5); - h.input_mut().events.push(egui::Event::PointerMoved(at)); - step(h); - h.input_mut().events.push(egui::Event::PointerButton { pos: at, button, pressed: true, modifiers: Default::default() }); - step(h); - h.input_mut().events.push(egui::Event::PointerButton { pos: at, button, pressed: false, modifiers: Default::default() }); - step(h); -} - -#[test] -fn rename_from_video_track_header_is_undoable() { - let mut s = Session::default(); - s.execute("file.openDemoProject", json!({})).expect("demo project"); - let mut h = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_cc| FilmcraftApp::new(s)); - for _ in 0..5 { - step(&mut h); - } - let original = h.state().session.active_sequence().expect("active sequence").video_tracks[0].name.clone(); - let id = h.state().session.active_sequence().expect("active sequence").video_tracks[0].id.0; - click(&mut h, "timeline.track.V1.name", egui::PointerButton::Secondary); - assert!(h.state().auto.find("timeline.track.V1.renameField").is_some(), "track rename popup should open"); - h.ctx.data_mut(|d| d.insert_temp(egui::Id::new(("timeline-track-rename", id)), "B-roll".to_string())); - step(&mut h); - click(&mut h, "timeline.track.V1.renameApply", egui::PointerButton::Primary); - assert_eq!(h.state().session.active_sequence().expect("active sequence").video_tracks[0].name, "B-roll"); - h.state_mut().session.execute("edit.undo", json!({})).expect("undo rename"); - assert_eq!(h.state().session.active_sequence().expect("active sequence").video_tracks[0].name, original); -} From b86dcf7bb3205ec562f0151c8fd74776a20b30d9 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 21:24:52 +0200 Subject: [PATCH 108/144] #227: transitions travel with the clips they sit on when moved Problem: moving a clip dropped the transitions at its edges (#227, point 8 of #374). `edit::move_items` deletes the moved clips before placing them again, and that deletion removes every transition that refers to them, so a fade on a clip's edge, or the cut between two clips moved together, vanished. Fix: before the move, collect the transitions whose clips all move onto one track by one offset, and put them back on that track shifted by the offset once the clips have landed (overwrite and insert moves). A crossfade whose partner stays behind is still dropped. Co-Authored-By: Claude Opus 5.5 --- crates/edit/src/lib.rs | 30 +++++++++++++++++++++++++++++- crates/edit/src/tests.rs | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 2f5a8382..200fe63b 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -487,17 +487,45 @@ pub fn move_items(seq: &mut Sequence, moves: &[(ClipId, TrackId, Tick)], insert_ it.start = (*start).max(Tick::ZERO); placed.push((*dest, it)); } - // carry transitions with moved clips? Premiere drops transitions whose partner is not moved. + let carried = carried_transitions(&work, &placed); + // a transition whose partner stays behind is dropped, as in Premiere delete_items(&mut work, &moves.iter().map(|m| m.0).collect::>()); if insert_mode { insert(&mut work, placed, ctx)?; } else { overwrite(&mut work, placed, ctx)?; } + for (dest, tr) in carried { + let t = track_mut(&mut work, dest)?; + if [tr.from, tr.to].into_iter().flatten().all(|c| t.item(c).is_some()) { + t.transitions.push(tr); + t.sort(); + } + } *seq = work; Ok(()) } +/// The transitions that travel with a move (#227, #374): those whose clips all move, onto one +/// track and by one offset (a fade at a moved clip's edge, or the cut between two clips that move +/// together), shifted to where those clips land. +fn carried_transitions(seq: &Sequence, placed: &[(TrackId, TrackItem)]) -> Vec<(TrackId, Transition)> { + let landing = |c: ClipId| { + let (dest, it) = placed.iter().find(|(_, it)| it.id == c)?; + let (_, old) = seq.find_item(c)?; + Some((*dest, it.start - old.start)) + }; + let mut carried = Vec::new(); + for tr in seq.all_tracks().filter(|t| !t.locked).flat_map(|t| t.transitions.iter()) { + let lands: Option> = [tr.from, tr.to].into_iter().flatten().map(landing).collect(); + let Some((&(dest, delta), rest)) = lands.as_deref().and_then(<[_]>::split_first) else { continue }; + if rest.iter().all(|l| *l == (dest, delta)) { + carried.push((dest, Transition { start: tr.start + delta, ..tr.clone() })); + } + } + carried +} + fn media_len(ctx: &EditCtx, item: &TrackItem) -> Option { if item.frame_hold.is_some() { return None; diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index ae258cb0..187bded0 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -464,6 +464,39 @@ fn transitions_stay_on_their_cuts() { fx.seq.check().unwrap(); } +#[test] +fn transitions_travel_with_moved_clips() { + // V1: a [0,10) fading in, b [10,20), c [20,30); b→c crossfade, c fading out + let mut fx = Fx::new(); + let (v1, v2) = (fx.v(0), fx.v(1)); + let a = fx.put(v1, 0, 10, 10); + let b = fx.put(v1, 10, 10, 10); + let c = fx.put(v1, 20, 10, 10); + let tr = |id, start, from, to| Transition { + id: TransitionId(id), + effect: filmcraft_project::find_effect("cross_dissolve").unwrap().instance(), + start: f(start), + duration: f(4), + from, + to, + align: Default::default(), + reverse: false, + }; + fx.seq.track_mut(v1).unwrap().transitions = vec![tr(1, 0, None, Some(a)), tr(2, 18, Some(b), Some(c)), tr(3, 26, Some(c), None)]; + let at = |fx: &Fx, track: TrackId| -> Vec<(u64, i64)> { fx.seq.track(track).unwrap().transitions.iter().map(|t| (t.id.0, R.frame_at(t.start))).collect() }; + let mut n = fx.next; + // a fade at a clip's edge goes with it, to another track too + move_items(&mut fx.seq, &[(a, v2, f(40))], false, &mut Fx::ctx(&mut n)).unwrap(); + assert_eq!((at(&fx, v1), at(&fx, v2)), (vec![(2, 18), (3, 26)], vec![(1, 40)])); + // a cut whose two clips move together keeps its transition, in an insert move as well + move_items(&mut fx.seq, &[(b, v1, f(60)), (c, v1, f(70))], true, &mut Fx::ctx(&mut n)).unwrap(); + assert_eq!(at(&fx, v1), vec![(2, 68), (3, 76)]); + // a crossfade whose partner stays behind is dropped; the other clip's fade stays with it + move_items(&mut fx.seq, &[(c, v1, f(90))], false, &mut Fx::ctx(&mut n)).unwrap(); + assert_eq!(at(&fx, v1), vec![(3, 96)]); + fx.seq.check().unwrap(); +} + #[test] fn rate_stretch_and_speed() { let mut fx = Fx::new(); From 2cab09e69b3f4e2866ceea94585a29941a68d0b7 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 21:29:55 +0200 Subject: [PATCH 109/144] #374: a clip's fades follow its edges on a regular trim Problem: dragging a clip's edge with the Selection tool (a regular trim) moved the edge but left a fade in from nothing / fade out to nothing where it was; only Ripple trims moved transitions along with the cut. Fix: after a regular trim, the trimmed clip's one-sided transitions are put back on its edges, and shortened when the clip gets shorter than them. Transitions between two clips are untouched (a regular trim cannot move their cut). Co-Authored-By: Claude Opus 5.5 --- crates/edit/src/lib.rs | 22 ++++++++++++++++++++++ crates/edit/src/tests.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 2f5a8382..1b978cf4 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -214,6 +214,26 @@ fn transitions_follow_cuts(before: &Sequence, after: &mut Sequence) { } } +/// Keep a clip's one-sided transitions (a fade in from nothing, a fade out to nothing) on its edges +/// after a regular trim moved them, no longer than the clip (#374). Transitions between two clips +/// are left alone: a regular trim cannot move their cut. +fn fades_follow_edges(track: &mut Track, clip: ClipId) { + let Some((start, end, len)) = track.item(clip).map(|i| (i.start, i.end(), i.duration)) else { return }; + for tr in &mut track.transitions { + match (tr.from, tr.to) { + (None, Some(to)) if to == clip => { + tr.duration = tr.duration.min(len); + tr.start = start; + } + (Some(from), None) if from == clip => { + tr.duration = tr.duration.min(len); + tr.start = end - tr.duration; + } + _ => {} + } + } +} + fn place(track: &mut Track, item: TrackItem) { let idx = track.items.partition_point(|i| i.start <= item.start); track.items.insert(idx, item); @@ -630,6 +650,8 @@ pub fn trim(seq: &mut Sequence, clip: ClipId, edge: Edge, mode: TrimMode, delta: } } transitions_follow_cuts(seq, &mut work); + } else if let Ok(track) = track_mut(&mut work, tid) { + fades_follow_edges(track, clip); } work.check().map_err(EditError::Other)?; *seq = work; diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index ae258cb0..f8a5cb8d 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -464,6 +464,34 @@ fn transitions_stay_on_their_cuts() { fx.seq.check().unwrap(); } +#[test] +fn fades_follow_a_regular_trim() { + let mut fx = Fx::new(); + let v1 = fx.v(0); + let a = fx.put(v1, 10, 20, 10); + let fade = |id, start, from, to| Transition { + id: TransitionId(id), + effect: filmcraft_project::find_effect("cross_dissolve").unwrap().instance(), + start: f(start), + duration: f(6), + from, + to, + align: Default::default(), + reverse: false, + }; + fx.seq.track_mut(v1).unwrap().transitions = vec![fade(1, 10, None, Some(a)), fade(2, 24, Some(a), None)]; + let spans = |fx: &Fx| fx.seq.track(v1).unwrap().transitions.iter().map(|t| (t.start, t.duration)).collect::>(); + let mut n = fx.next; + trim(&mut fx.seq, a, Edge::In, TrimMode::Regular, f(2), &mut Fx::ctx(&mut n)).unwrap(); + trim(&mut fx.seq, a, Edge::Out, TrimMode::Regular, f(4), &mut Fx::ctx(&mut n)).unwrap(); + // the fade in starts with the clip (12), the fade out ends with it (34) + assert_eq!(spans(&fx), vec![(f(12), f(6)), (f(28), f(6))]); + // a clip trimmed shorter than its fades keeps them inside it + trim(&mut fx.seq, a, Edge::Out, TrimMode::Regular, -f(18), &mut Fx::ctx(&mut n)).unwrap(); + assert_eq!(spans(&fx), vec![(f(12), f(4)), (f(12), f(4))]); + fx.seq.check().unwrap(); +} + #[test] fn rate_stretch_and_speed() { let mut fx = Fx::new(); From 7f59211912f932e46b64bdc879fdb2f5785f702d Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 21:31:27 +0200 Subject: [PATCH 110/144] #228: Export Frame reports a frame still loading instead of saving it blank Problem: file.exportFrame rendered and wrote the still without looking at filmcraft_media::pending. On the web, a frame WebCodecs is still decoding (or whose Blob bytes are still loading) is left out of the render, so the command succeeded with a transparent/blank picture passed off as the requested frame. Fix: clear the pending flag before rendering and check it afterwards; when a read was deferred, fail with "the frame is still loading; export it again in a moment" and write nothing. A later call exports the real frame. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/frame_export_tests.rs | 51 +++++++++++++++++++++++++ crates/engine/src/keyboard.rs | 11 +++++- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/frame_export_tests.rs b/crates/engine/src/frame_export_tests.rs index 12702ba6..3e2a0003 100644 --- a/crates/engine/src/frame_export_tests.rs +++ b/crates/engine/src/frame_export_tests.rs @@ -101,3 +101,54 @@ fn frame_export_snapshots_are_independent_of_later_monitor_selection() { Arc::make_mut(&mut s.project).sequence_mut(seq).unwrap().settings.frame_rate = FrameRate { num: 1, den: i64::MAX }; assert!(keyboard::frame_export_target(&s, &json!({})).is_err()); } + +/// A source whose pictures are still on their way, as a WebCodecs decode or a Blob read on the web. +struct Loading(filmcraft_media::MediaInfo); + +impl filmcraft_media::MediaSource for Loading { + fn info(&self) -> &filmcraft_media::MediaInfo { + &self.0 + } + fn video_frame(&self, _: filmcraft_media::FrameRequest) -> filmcraft_media::Result> { + filmcraft_media::pending::mark(); + Err(filmcraft_media::MediaError::Decode("still decoding".into())) + } + fn audio(&self, _: i64, _: usize, _: u32) -> filmcraft_media::Result { + filmcraft_media::pending::mark(); + Err(filmcraft_media::MediaError::Decode("still loading".into())) + } +} + +#[test] +fn a_frame_still_loading_is_reported_not_saved_blank() { + let (mut s, item) = session(); + let generator = s.media.cached(item).unwrap(); + s.media.insert(item, Arc::new(Loading(generator.info().clone()))); + let dir = std::env::temp_dir().join(format!("fc-frame-loading-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + for target in ["program", "source"] { + let path = dir.join(format!("{target}.png")); + let err = s.execute("file.exportFrame", json!({"target":target,"path":path.to_string_lossy()})).unwrap_err(); + assert!(err.to_string().contains("still loading"), "{target}: {err}"); + assert!(!path.exists(), "{target}: nothing is written"); + } + // once the picture is there, the same export succeeds + s.media.insert(item, generator); + for target in ["program", "source"] { + let path = dir.join(format!("{target}.png")); + s.execute("file.exportFrame", json!({"target":target,"path":path.to_string_lossy()})).unwrap(); + assert!(path.exists(), "{target}"); + } + drop(s); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn an_earlier_deferred_read_does_not_fail_the_export() { + let (mut s, _) = session(); + filmcraft_media::pending::mark(); + let path = std::env::temp_dir().join(format!("fc-frame-stale-pending-{}.png", std::process::id())); + s.execute("file.exportFrame", json!({"target":"program","path":path.to_string_lossy()})).unwrap(); + assert!(!filmcraft_media::pending::is_set()); + std::fs::remove_file(path).unwrap(); +} diff --git a/crates/engine/src/keyboard.rs b/crates/engine/src/keyboard.rs index ad5f5d2a..57ddf72f 100644 --- a/crates/engine/src/keyboard.rs +++ b/crates/engine/src/keyboard.rs @@ -635,13 +635,20 @@ fn export_frame(s: &mut Session, p: &Value) -> Result { } }; let provider = s.media.full_res_provider(s.project.clone(), s.services.clone()); + // On the web a frame can still be decoding (WebCodecs) or its bytes loading (Blob reads): the + // render then leaves that clip out, so saving it would pass a blank or partial picture off as + // the requested frame (#228). Report it instead and write nothing; a later call succeeds. + let _ = filmcraft_media::pending::take(); let img = if target.source { filmcraft_render::render_item(&s.project, target.item, target.time, 1.0, &provider) - .ok_or_else(|| bad("file.exportFrame", "cannot decode the Source video frame"))? } else { let opts = filmcraft_render::RenderOptions { scale: 1.0, captions: true, ..Default::default() }; - filmcraft_render::render_sequence(&s.project, target.item, target.time, opts, &provider) + Some(filmcraft_render::render_sequence(&s.project, target.item, target.time, opts, &provider)) }; + if filmcraft_media::pending::take() { + return Err(EngineError::Other("the frame is still loading; export it again in a moment".into())); + } + let img = img.ok_or_else(|| bad("file.exportFrame", "cannot decode the Source video frame"))?; let (w, h) = ( u32::try_from(img.w).map_err(|_| bad("file.exportFrame", "image width exceeds limits"))?, u32::try_from(img.h).map_err(|_| bad("file.exportFrame", "image height exceeds limits"))?, From 1580ebb520c9e22d0f94047db0656d2a84d07fca Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 21:33:45 +0200 Subject: [PATCH 111/144] #266: quitting with unsaved changes asks to save first Problem: Quit, Cmd+Q or the window's close button exited at once with unsaved changes, leaving them to the recovery journal, so every launch after an unsaved session offered a recovery (docs/project-files.md: no "Save changes?" prompt on quit yet). Fix: a close request with unsaved changes is cancelled and the existing Save Project prompt asks Cancel / Don't Save / Save (automation ids quit.*), as in Premiere. Don't Save discards the changes and their recovery snapshot; Save on a never-saved project (here and in Close Project) opens the Save dialog and returns to the prompt when cancelled. The control channel's app.quit still quits without asking. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/control.rs | 2 + crates/ui-egui/src/lib.rs | 8 +++ crates/ui-egui/src/panels/clip_dialogs.rs | 60 +++++++++++++++---- crates/ui-egui/tests/clip_dialogs_ui.rs | 72 +++++++++++++++++++++++ docs/control-protocol.md | 2 +- docs/project-files.md | 8 ++- 6 files changed, 135 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 3cd86c35..cd408481 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -429,6 +429,8 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) ok(Value::Null) } "app.quit" => { + // scripted quits don't wait on the save prompt; unsaved changes stay in the recovery journal + app.quit_confirmed = true; ctx.send_viewport_cmd(egui::ViewportCommand::Close); ok(Value::Null) } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0faa82e1..4c5f4ab0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -291,6 +291,9 @@ pub struct FilmcraftApp { /// Fault injection for robustness tests: the next UI pass panics. #[doc(hidden)] pub panic_next_frame: bool, + /// The next close request quits without asking to save (Save / Don't Save was chosen in the + /// quit prompt, or the control channel's `app.quit`). + pub quit_confirmed: bool, fonts_ready: bool, pub integrated_titlebar: bool, pub last_timeline_width: f32, @@ -511,6 +514,7 @@ impl FilmcraftApp { styled: false, ui_error: None, panic_next_frame: false, + quit_confirmed: false, fonts_ready: false, integrated_titlebar: false, last_timeline_width: 1000.0, @@ -1882,6 +1886,10 @@ impl eframe::App for FilmcraftApp { self.playback.hidden = true; } self.timeline_still = if self.ui.timeline.animating() { 0 } else { self.timeline_still.saturating_add(1) }; + // Quitting with unsaved changes asks to save them first. + if ctx.input(|i| i.viewport().close_requested()) && panels::clip_dialogs::intercept_quit(self) { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + } let had_synthetic = !self.synthetic.is_empty(); self.drain_control(ctx); if !self.synthetic.is_empty() && !had_synthetic { diff --git a/crates/ui-egui/src/panels/clip_dialogs.rs b/crates/ui-egui/src/panels/clip_dialogs.rs index 104961e3..2d5b8b53 100644 --- a/crates/ui-egui/src/panels/clip_dialogs.rs +++ b/crates/ui-egui/src/panels/clip_dialogs.rs @@ -1,7 +1,7 @@ //! Edit / Clip / File menu dialogs (M3.10): Paste Attributes, Remove Attributes, Offline File, //! Make Subclip, Edit Subclip, Modify ▸ Audio Channels, Modify ▸ Timecode, Frame Hold Options, -//! Field Options, Clip Speed / Duration, Nest… (Nested Sequence Name), and the Close Project -//! "save changes?" prompt. +//! Field Options, Clip Speed / Duration, Nest… (Nested Sequence Name), and the Close Project and +//! Quit "save changes?" prompts. //! //! The open dialog lives in `UiState::clip_dialog` (serde): the engine command it runs and the //! parameters being edited — the same JSON the command takes — so agents can open a dialog from @@ -26,7 +26,9 @@ //! - Field Options `fieldOptions.*`: `reverseFieldDominance`, `processing.`; //! - Clip Speed / Duration `speedDuration.*`: `speed`, `reverse`, `ripple`, //! `interpolation.`; -//! - Close Project `closeProject.save`, `closeProject.dontSave`, `closeProject.cancel`. +//! - Close Project `closeProject.save`, `closeProject.dontSave`, `closeProject.cancel`; +//! - Quit (asked when the window is closed with unsaved changes, see [`intercept_quit`]) +//! `quit.save`, `quit.dontSave`, `quit.cancel`. use egui::{Align2, RichText}; use filmcraft_project::{AudioChannelMap, AudioChannels, ItemKind, TrackKind}; @@ -56,6 +58,7 @@ fn meta(command: &str) -> Option<(&'static str, &'static str)> { "clip.fieldOptions" => (tl!("Field Options"), "fieldOptions"), "clip.speedDuration" => (tl!("Clip Speed / Duration"), "speedDuration"), "file.closeProject" => (tl!("Save Project"), "closeProject"), + "app.quit" => (tl!("Save Project"), "quit"), _ => return None, }) } @@ -89,7 +92,7 @@ pub fn route(app: &mut FilmcraftApp, id: &str, params: &Value) -> Option Option bool { + if app.quit_confirmed || !app.session.is_dirty() { + return false; + } + if app.ui.clip_dialog.as_ref().is_none_or(|d| d.command != "app.quit") { + app.ui.clip_dialog = Some(ClipDialogDraft { command: "app.quit".into(), params: json!({}), info: Value::Null, error: String::new() }); + } + true +} + /// Initial parameters (and display info) of a command's dialog. fn defaults(app: &FilmcraftApp, id: &str) -> (Value, Value) { let s = &app.session; @@ -290,7 +306,8 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut elems: Elems = Vec::new(); let mut action: Option<&'static str> = None; let project_name = app.session.project.name.clone(); - let can_save = app.session.path.is_some(); + // a never-saved project is saved through the Save dialog + let can_save = app.session.path.is_some() || app.hooks.pick_save.is_some(); // the title is translated; the window keeps one id whatever the interface language let id = egui::Id::new(("clip-dialog", pre)); egui::Window::new(title).id(id).collapsible(false).resizable(false).default_width(360.0).anchor(Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { @@ -520,7 +537,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { } } } - "file.closeProject" => { + "file.closeProject" | "app.quit" => { ui.label(tlf!("Save changes to “{project_name}” before closing?", project_name)); ui.add_space(8.0); ui.horizontal(|ui| { @@ -586,16 +603,33 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { } } Some("dontSave") | Some("save") => { - if action == Some("save") - && let Err(e) = app.session.execute("file.save", json!({})) - { - app.ui.status = e.to_string(); - app.ui.clip_dialog = None; - return; + if action == Some("save") { + let saved = if app.session.path.is_some() { + app.session.execute("file.save", json!({})).map(|_| ()).map_err(|e| e.to_string()) + } else { + app.file_dialog("file.save", &json!({})).map(|_| ()) + }; + if let Err(e) = saved { + app.ui.status = e; + app.ui.clip_dialog = None; + return; + } + // the Save dialog was cancelled: back to the prompt + if app.session.is_dirty() { + app.ui.clip_dialog = Some(d); + return; + } } - if let Err(e) = app.session.execute("file.closeProject", json!({"force": true})) { + // Quit ▸ Don't Save discards the changes: closing the project also clears their recovery + // snapshot, so the next launch doesn't offer them back + let close = d.command != "app.quit" || app.session.is_dirty(); + if close && let Err(e) = app.session.execute("file.closeProject", json!({"force": true})) { app.ui.status = e.to_string(); } + if d.command == "app.quit" { + app.quit_confirmed = true; + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } app.ui.clip_dialog = None; return; } diff --git a/crates/ui-egui/tests/clip_dialogs_ui.rs b/crates/ui-egui/tests/clip_dialogs_ui.rs index d02c1666..b6049371 100644 --- a/crates/ui-egui/tests/clip_dialogs_ui.rs +++ b/crates/ui-egui/tests/clip_dialogs_ui.rs @@ -88,6 +88,25 @@ impl Driver { fn item_named(&mut self, name: &str) -> u64 { self.app().session.project.items.values().find(|i| i.name == name).unwrap().id.0 } + + /// The window is asked to close (Quit, Cmd+Q, its close button) for one frame. + fn request_close(&mut self) { + self.harness.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close); + self.frames(1); + } + + /// The app sent `cmd` to the window in the last frame. + fn sent(&self, cmd: &egui::ViewportCommand) -> bool { + self.harness.output().viewport_output.get(&egui::ViewportId::ROOT).is_some_and(|v| v.commands.contains(cmd)) + } + + /// Make an unsaved change. + fn edit(&mut self) { + let c = self.v1(0); + self.exec("timeline.select", json!({"clips": [c.id.0]})); + self.menu("edit.label.yellow"); + assert!(self.app().session.is_dirty()); + } } #[test] @@ -269,3 +288,56 @@ fn speed_duration_from_the_clip_menu_opens_the_dialog() { assert_eq!(got.speed, 2.0); assert_eq!(got.time_interpolation, filmcraft_project::TimeInterpolation::FrameBlending); } + +#[test] +fn quitting_with_unsaved_changes_asks_to_save() { + let mut d = Driver::new(); + d.edit(); + // the close is cancelled and the prompt asks instead + d.request_close(); + assert!(d.sent(&egui::ViewportCommand::CancelClose), "the window must stay open"); + d.frames(2); + assert!(d.has("quit.save") && d.has("quit.dontSave") && d.has("quit.cancel")); + // Cancel keeps the app and the changes + d.click("quit.cancel"); + assert!(d.app().ui.clip_dialog.is_none()); + assert!(d.app().session.is_dirty() && !d.app().quit_confirmed); + d.request_close(); + assert!(d.sent(&egui::ViewportCommand::CancelClose), "asks again"); + d.frames(2); + // Don't Save discards the changes (and their recovery snapshot) and closes the window + d.click("quit.dontSave"); + assert!(d.app().quit_confirmed && !d.app().session.is_dirty()); + assert!(d.app().ui.clip_dialog.is_none()); + d.request_close(); + assert!(!d.sent(&egui::ViewportCommand::CancelClose), "the confirmed quit goes through"); +} + +#[test] +fn quit_save_on_a_new_project_uses_the_save_dialog_and_scripted_quit_does_not_ask() { + let path = std::env::temp_dir().join(format!("fc-quit-save-{}.fcproj", std::process::id())); + let _ = std::fs::remove_file(&path); + let mut d = Driver::new(); + d.app().session.path = None; + d.edit(); + let picked = path.to_string_lossy().into_owned(); + let mut answers = vec![None, Some(picked)]; + d.app().hooks.pick_save = Some(Box::new(move |_| if answers.is_empty() { None } else { answers.remove(0) })); + d.request_close(); + d.frames(2); + // the Save dialog is cancelled: back to the prompt, nothing quits + d.click("quit.save"); + assert!(d.app().ui.clip_dialog.is_some() && !d.app().quit_confirmed); + // saved: the project is written and the window closes + d.click("quit.save"); + assert!(d.app().quit_confirmed && !d.app().session.is_dirty()); + assert!(path.exists(), "saved to the chosen file"); + let _ = std::fs::remove_file(&path); + d.request_close(); + assert!(!d.sent(&egui::ViewportCommand::CancelClose)); + // the control channel's app.quit quits without the prompt + let mut d = Driver::new(); + d.edit(); + d.ok("app.quit", json!({})); + assert!(d.app().quit_confirmed); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 28b86c30..2a287a88 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -38,7 +38,7 @@ Methods (handlers in `crates/ui-egui/src/control.rs`): | `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.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`, `ui.focus`, `app.quit` | | `ui.focus` is the only request that activates the app and takes keyboard focus | +| `ui.resize`, `ui.focus`, `app.quit` | | `ui.focus` is the only request that activates the app and takes keyboard focus. `app.quit` quits without the save prompt (unsaved changes stay in the recovery journal); the command `app.quit` (Quit, Cmd+Q) asks first | | `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)) | **Timeline thumbnails.** `ui.set {timeline: {thumbnails}}` sets the Timeline wrench menu's Show Video diff --git a/docs/project-files.md b/docs/project-files.md index 5c1b08fd..9f5be728 100644 --- a/docs/project-files.md +++ b/docs/project-files.md @@ -152,9 +152,11 @@ Premiere's auto-save can lose up to one interval of work. FilmCraft also keeps a over the original file. The recovered state goes into the new session's own journal before the old journal is deleted. - A normal quit with nothing unsaved removes the session directory. Quitting with unsaved changes - keeps the snapshot, marked `cleanExit`, and FilmCraft offers it on the next launch ("FilmCraft was - closed while … had unsaved changes"). There is no "Save changes?" prompt on quit yet, so this - keeps the work. + (Quit, Cmd+Q or the window's close button) first asks *"Save changes to 'X' before closing?"* + with **Cancel**, **Don't Save** and **Save** (Save on a never-saved project opens the Save dialog); + Don't Save discards the changes and their snapshot. A quit that does not ask (the control + channel's `app.quit`, the OS ending the session) keeps the snapshot, marked `cleanExit`, and FilmCraft offers it on the next launch ("FilmCraft was + closed while … had unsaved changes"). Desktop flags: `--recover` recovers the newest candidate without asking. `--no-recover` starts without asking; the changes stay available through File ▸ Recover Unsaved Changes…. `--data-dir From 1ff2b9fc8944a13aad8f69f9bb434632db6f1335 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 21:37:09 +0200 Subject: [PATCH 112/144] #284: panel menu Maximize Frame can be restored; an empty dock reopens panels Problem: the panel menu's Maximize Frame replaced the whole dock tree with a one-panel group, so there was no way back short of resetting the workspace, and closing that panel left an empty dock. DockNode::open_near only inserted beside the requested neighbour, Project or Program, so with those gone (or no panel left) every Window > panel command was a silent no-op. Fix: the panel menu now toggles the same Maximize/Restore Frame state as ` / Shift+` (labelled Restore Frame while maximized) and keeps the tree; a maximized panel that was closed counts as restored, and switching workspace clears it. open_near falls back to the first group, even an empty one, as its doc comment promised. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dock.rs | 36 +++++++++++++++++++++++++-- crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/zh-cn.tsv | 1 + crates/ui-egui/src/lib.rs | 2 ++ crates/ui-egui/src/panels/keyboard.rs | 7 ++++-- crates/ui-egui/src/panels/mod.rs | 8 ++++-- crates/ui-egui/tests/panels_ui.rs | 31 +++++++++++++++++++++++ 8 files changed, 81 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 05acf45b..ef680f5d 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -401,8 +401,19 @@ impl DockNode { } } } - if !add(self, p, near) && !add(self, p, PanelKind::Project) { - add(self, p, PanelKind::Program); + /// The first group of the tree, even an empty one (closing the last panel leaves the + /// root as an empty group: a panel opened then must still have somewhere to go). + fn add_first(n: &mut DockNode, p: PanelKind) { + match n { + DockNode::Split { a, .. } => add_first(a, p), + DockNode::Tabs { panels, active } => { + panels.push(p); + *active = panels.len() - 1; + } + } + } + if !add(self, p, near) && !add(self, p, PanelKind::Project) && !add(self, p, PanelKind::Program) { + add_first(self, p); } } } @@ -782,4 +793,25 @@ mod tests { let back: DockNode = serde_json::from_str(&s).unwrap(); assert_eq!(back, d); } + + /// #284: with Project and Program closed (or every panel closed) a panel still opens. + #[test] + fn open_near_falls_back_to_the_first_group() { + let mut d = workspace("Editing"); + let mut all = Vec::new(); + d.panels(&mut all); + for p in all { + d.close(p); + } + assert_eq!(d, DockNode::Tabs { panels: vec![], active: 0 }); + d.open_near(PanelKind::Program, PanelKind::Project); + assert!(d.is_visible(PanelKind::Program)); + d.restore_timeline(); + assert!(d.is_visible(PanelKind::Timeline)); + // a tree without Project or Program: the panel joins the first group + let mut d = hsplit(SplitSize::Ratio(0.5), tabs(&[PanelKind::Effects], 0), tabs(&[PanelKind::Markers], 0)); + d.open_near(PanelKind::Info, PanelKind::Source); + assert!(d.is_visible(PanelKind::Info)); + assert_eq!(d, hsplit(SplitSize::Ratio(0.5), tabs(&[PanelKind::Effects, PanelKind::Info], 1), tabs(&[PanelKind::Markers], 0))); + } } diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 3f45ce9d..4aae9a52 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -497,6 +497,7 @@ Close Panel Cerrar panel Close Other Timeline Panels Cerrar los demás paneles de línea de tiempo Maximize Frame Maximizar marco + Restore Frame Restaurar marco Restore Workspace Restaurar espacio de trabajo Reveal Sequence in Project Mostrar secuencia en el proyecto Video Thumbnails Miniaturas de video diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 3d54f873..d94a5e41 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -495,6 +495,7 @@ Close Panel パネルを閉じる Close Other Timeline Panels 他のタイムラインパネルを閉じる Maximize Frame パネルを最大化 + Restore Frame パネルを元に戻す Restore Workspace ワークスペースを復元 Reveal Sequence in Project プロジェクトでシーケンスを表示 Video Thumbnails ビデオサムネイル diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index e504e341..30b2831f 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -488,6 +488,7 @@ Close Panel 关闭面板 Close Other Timeline Panels 关闭其他时间轴面板 Maximize Frame 最大化帧 + Restore Frame 还原帧 Restore Workspace 恢复工作区 Reveal Sequence in Project 在项目面板中显示序列 Video Thumbnails 视频缩略图 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0faa82e1..6ec07e12 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -752,6 +752,8 @@ impl FilmcraftApp { pub fn set_workspace(&mut self, name: &str) { self.ui.workspace = name.to_string(); + // a workspace is shown whole: no frame stays maximized over it + self.ui.keys.maximized = None; self.ui.dock = dock::saved_layout(&self.workspaces, name); if self.workspaces.current != name { let mut next = self.workspaces.clone(); diff --git a/crates/ui-egui/src/panels/keyboard.rs b/crates/ui-egui/src/panels/keyboard.rs index 674769d6..55ee8031 100644 --- a/crates/ui-egui/src/panels/keyboard.rs +++ b/crates/ui-egui/src/panels/keyboard.rs @@ -232,8 +232,11 @@ pub fn route(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: &Val // ------------------------------------------------------------------ frames and panels -fn toggle_maximize(app: &mut FilmcraftApp, p: PanelKind) -> Value { - app.ui.keys.maximized = if app.ui.keys.maximized.is_some() { None } else { Some(p) }; +/// Maximize `p`, or restore the layout when a panel is maximized (also the panel menu's Maximize / +/// Restore Frame). A maximized panel that has since been closed counts as restored. +pub(crate) fn toggle_maximize(app: &mut FilmcraftApp, p: PanelKind) -> Value { + let current = app.ui.keys.maximized.filter(|q| app.ui.dock.contains(*q)); + app.ui.keys.maximized = if current.is_some() { None } else { Some(p) }; if let Some(p) = app.ui.keys.maximized { app.ui.dock.activate(p); app.ui.focused = p; diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index bcb386fb..4351e4fc 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -244,8 +244,12 @@ pub fn panel_menu_popup(app: &mut FilmcraftApp, ui: &mut egui::Ui) { app.ui.dock.close(p); close = true; } - if ui.button(tl!("Maximize Frame")).clicked() { - app.ui.dock = crate::dock::DockNode::Tabs { panels: vec![p], active: 0 }; + // the same frame state as ` / Shift+`: the dock tree is kept, so Restore brings it back + let maximized = app.ui.keys.maximized.is_some_and(|q| app.ui.dock.contains(q)); + let r = ui.button(if maximized { tl!("Restore Frame") } else { tl!("Maximize Frame") }); + app.auto.add(&format!("panel.menu.{}.maximize", p.id()), r.rect, if maximized { "Restore Frame" } else { "Maximize Frame" }); + if r.clicked() { + keyboard::toggle_maximize(app, p); close = true; } if ui.button(tl!("Restore Workspace")).clicked() { diff --git a/crates/ui-egui/tests/panels_ui.rs b/crates/ui-egui/tests/panels_ui.rs index e395f4a8..836a8f94 100644 --- a/crates/ui-egui/tests/panels_ui.rs +++ b/crates/ui-egui/tests/panels_ui.rs @@ -177,6 +177,37 @@ fn every_panel_opens_from_the_window_menu() { } } +/// #284 point 3: the panel menu's Maximize Frame keeps the layout (Restore Frame brings it back), +/// and closing every panel still leaves a dock that Window ▸ can reopen panels into. +#[test] +fn panel_menu_maximize_restores_and_an_empty_dock_reopens() { + use filmcraft_ui_egui::dock::{DockNode, PanelKind}; + let mut d = Driver::demo(); + let layout = d.app().ui.dock.clone(); + d.click("panel.menu.Program"); + assert_eq!(d.label("panel.menu.Program.maximize"), "Maximize Frame"); + d.click("panel.menu.Program.maximize"); + assert_eq!(d.app().ui.keys.maximized, Some(PanelKind::Program)); + assert!(!d.has("panel.Timeline"), "only the maximized panel is shown"); + d.click("panel.menu.Program"); + assert_eq!(d.label("panel.menu.Program.maximize"), "Restore Frame"); + d.click("panel.menu.Program.maximize"); + assert_eq!(d.app().ui.keys.maximized, None); + assert_eq!(d.app().ui.dock, layout, "the layout survives maximize / restore"); + assert!(d.has("panel.Timeline")); + + let mut all = Vec::new(); + layout.panels(&mut all); + for p in all { + d.ok("ui.panel.close", json!({"panel": p.id()})); + } + assert_eq!(d.app().ui.dock, DockNode::Tabs { panels: vec![], active: 0 }); + d.ok("ui.menu.invoke", json!({"id": "window.panel.Program"})); + d.ok("ui.menu.invoke", json!({"id": "window.panel.Timeline"})); + d.frames(3); + assert!(d.has("panel.tab.Program") && d.has("panel.tab.Timeline")); +} + #[test] fn lumetri_scopes_layouts_menu_and_footer() { let mut d = Driver::demo(); From 13d8e73e8ef288ae86c9743186a02d0838643508 Mon Sep 17 00:00:00 2001 From: Tommaso Maccagni Date: Sat, 10 Oct 2026 17:22:04 +0200 Subject: [PATCH 113/144] export: a frame planned as one CPU image does not take a pooled GPU renderer With GPU rendering on, a frame the planner hands back whole (an adjustment layer, a complex transition, an HDR / wide-gamut sequence) was still rendered by render_sequence, but inside RendererPool::with: as many frames at a time as there are pooled renderers (2 to 4) instead of one per export thread. A timeline graded on an adjustment layer exported slower with Auto than with Off. Pipeline::frame asks the planner first (plan::is_cpu_frame, the test plan_frame itself makes) and renders such a frame on the CPU directly. It counts as a fallback now, not as a GPU frame. The picture does not change. Co-Authored-By: Claude Opus 5.5 --- crates/export/src/pipeline.rs | 8 ++++++-- crates/export/src/tests.rs | 30 ++++++++++++++++++++++++++++++ crates/render/src/plan.rs | 21 +++++++++++++++------ 3 files changed, 51 insertions(+), 8 deletions(-) diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index 306aef2f..a2c4db22 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -213,8 +213,12 @@ impl Pipeline { pub fn frame(&self, f: i64, sources: &dyn SourceProvider) -> (Vec, Vec) { let t = self.rate.tick_of(f); // The registered GPU renderer goes first; `None` (no adapter, a plan the GPU cannot - // draw, any internal error) falls back to the CPU reference renderer below. - let gpu = match self.renderer.as_ref() { + // draw, any internal error) falls back to the CPU reference renderer below. A frame the + // planner hands back as one CPU image anyway (an adjustment layer, a complex transition, + // HDR) does not take a pooled renderer: it would render on the CPU inside the pool, as + // many frames at a time as there are renderers instead of one per export thread. + let pool = self.renderer.as_ref().filter(|_| !filmcraft_render::plan::is_cpu_frame(&self.project, self.seq, t)); + let gpu = match pool { Some(pool) => { let asked = web_time::Instant::now(); pool.with(|r| { diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs index 656ddd9f..ef7b182a 100644 --- a/crates/export/src/tests.rs +++ b/crates/export/src/tests.rs @@ -357,6 +357,36 @@ fn gpu_hook_scenarios() { } // at most one panic per pooled renderer (4): none is used again after it panicked assert!(PANICS.load(std::sync::atomic::Ordering::SeqCst) <= 4, "renderer reused after a panic: {}", PANICS.load(std::sync::atomic::Ordering::SeqCst)); + + // 5. A frame the planner hands back as one CPU image (an adjustment layer over the clip from + // frame 12) is rendered by the CPU without taking a pooled renderer: it counts as a + // fallback, and the renderer's picture reaches only the frames before it. + crate::reset_frame_renderers_for_tests(); + crate::register_frame_renderer(|| Some(Box::new(FakeRenderer) as Box)); + let (p, seq, m) = project(); + let mut p = Arc::unwrap_or_clone(p); + let r = FrameRate::FPS_24; + let adj = p.add_item("adj", Label::Iris, ItemKind::AdjustmentLayer { width: 320, height: 180, rate: r, duration: Tick(TICKS_PER_SECOND) }, None); + let over = p.make_track_item(adj, TrackKind::Video, r.tick_of(12), TimeRange::new(Tick::ZERO, r.tick_of(12)), r).unwrap(); + let v2 = filmcraft_project::TrackId(p.alloc_id()); + let q = p.sequence_mut(seq).unwrap(); + q.video_tracks.push(filmcraft_project::Track::new(v2, TrackKind::Video, "Video 2".into())); + q.video_tracks[1].items.push(over); + let p = Arc::new(p); + let mk24 = |path: &String, gpu: crate::GpuRendering| { + let mut s = ExportSettings { format: Format::PngSequence, path: path.clone(), ..Default::default() }; + s.range = Some(TimeRange::new(Tick::ZERO, r.tick_of(24))); + s.gpu_rendering = gpu; + export(&p, seq, &s, &m, &Progress::default()).unwrap() + }; + mk24(&tmp("adj-off.png"), crate::GpuRendering::Off); + mk24(&tmp("adj-auto.png"), crate::GpuRendering::Auto); + let frame = |name: &str, f: &str| std::fs::read(tmp(&format!("{name}{f}.png"))).unwrap(); + assert_ne!(frame("adj-off", "011"), frame("adj-auto", "011"), "a layered frame goes to the renderer"); + for f in ["012", "023"] { + assert_eq!(frame("adj-off", f), frame("adj-auto", f), "frame {f}: an adjustment layer frame is the CPU's"); + } + assert_eq!((gpu_render_stats().frames, gpu_render_stats().fallbacks), (12, 12), "{:?}", gpu_render_stats()); crate::reset_frame_renderers_for_tests(); } diff --git a/crates/render/src/plan.rs b/crates/render/src/plan.rs index f986711c..92b333cd 100644 --- a/crates/render/src/plan.rs +++ b/crates/render/src/plan.rs @@ -191,12 +191,7 @@ fn gpu_chain<'a>(project: &Project, item: &'a TrackItem, opts: RenderOptions) -> pub fn plan_frame(project: &Project, seq_id: ItemId, t: Tick, opts: RenderOptions, sources: &dyn SourceProvider) -> FramePlan { let Some(seq) = project.sequence(seq_id) else { return FramePlan::Image(crate::Image::new(1, 1)) }; let (w, h) = output_size(seq, opts.scale); - // HDR / wide-gamut sequences composite and convert on the CPU. - if !seq.settings.color.is_plain() { - return FramePlan::Image(crate::render_sequence(project, seq_id, t, opts, sources)); - } - // Whole-frame fallback: adjustment layers or complex transitions anywhere at t. - if !layered_at(project, seq, t) { + if whole_frame_on_cpu(project, seq, t) { return FramePlan::Image(crate::render_sequence(project, seq_id, t, opts, sources)); } let mut layers = PlanStack::default(); @@ -214,6 +209,20 @@ pub fn plan_frame(project: &Project, seq_id: ItemId, t: Tick, opts: RenderOption layers.finish(w, h) } +/// Whether [`plan_frame`] hands the frame at `t` back as one CPU image ([`FramePlan::Image`]). A +/// GPU renderer gains nothing on such a frame, so a caller with only a few of them (the export's +/// pool) can render it on the CPU without taking one. +pub fn is_cpu_frame(project: &Project, seq_id: ItemId, t: Tick) -> bool { + project.sequence(seq_id).is_none_or(|seq| whole_frame_on_cpu(project, seq, t)) +} + +/// The frames [`plan_frame`] does not plan: an HDR / wide-gamut sequence composites and converts +/// on the CPU, and so does a frame with an adjustment layer or a complex transition anywhere at +/// `t` (whole-frame fallback). +fn whole_frame_on_cpu(project: &Project, seq: &Sequence, t: Tick) -> bool { + !seq.settings.color.is_plain() || !layered_at(project, seq, t) +} + /// Whether the frame of `seq` at `t` can be planned as layers: no adjustment layer and no /// transition the compositor cannot mix itself is showing. fn layered_at(project: &Project, seq: &Sequence, t: Tick) -> bool { From d215d0e8a3390d9f15e5b538c67de6763e39716b Mon Sep 17 00:00:00 2001 From: Tommaso Maccagni Date: Sat, 10 Oct 2026 17:22:56 +0200 Subject: [PATCH 114/144] gpu: a build without a wgpu backend exports on the CPU instead of failing filmcraft-gpu asks wgpu for no backend (`default-features = false`); a build that includes the app gets them through eframe. A CLI built on its own (`cargo run --release -p filmcraft-cli`, as the README and docs/contributing.md do) has none, and `wgpu::Instance::default()` panics there: an export with GPU rendering on ended as "internal error (see the crash log); the job was stopped". ExportRenderer::new returns None in such a build, like a host without an adapter. Co-Authored-By: Claude Opus 5.5 --- crates/gpu/src/export_renderer.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/gpu/src/export_renderer.rs b/crates/gpu/src/export_renderer.rs index b97a5acf..f86f51a8 100644 --- a/crates/gpu/src/export_renderer.rs +++ b/crates/gpu/src/export_renderer.rs @@ -98,12 +98,19 @@ pub struct ExportRenderer { } impl ExportRenderer { - /// The off-screen device, or `None` when this host has no usable adapter. + /// The off-screen device, or `None` when this host has no usable adapter or this build no + /// wgpu backend. pub fn new() -> Option { // `None` is kept too: a machine without an adapter does not search again on every export static SHARED: std::sync::OnceLock> = std::sync::OnceLock::new(); let (instance, device, queue) = SHARED .get_or_init(|| { + // A build without a wgpu backend for this platform (`cargo build -p filmcraft-cli` + // alone: this crate asks for none) has no GPU to offer, and `Instance::default()` + // panics there. + if wgpu::Instance::enabled_backend_features().is_empty() { + return None; + } let instance = wgpu::Instance::default(); let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())).ok()?; let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()?; From ba5727029aebf1f463bb562de213c8125618de26 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 21:46:06 +0200 Subject: [PATCH 115/144] #626: undecodable video shows the MEDIA UNREADABLE slate, not black Problem: a file that opens but whose picture FilmCraft cannot decode (H.264 High 10 / 4:2:2 / 4:4:4, interlaced, a codec without a decoder) rendered black with no sign of why. The H.264 and HEVC decoders' "unsupported" errors reached the media source as generic decode errors, and the renderer drops frames that fail. Fix: the H.264 / HEVC wrappers report unsupported features as CodecError::Unsupported. The media pool wraps every file source it opens so a frame failing as unsupported renders the existing MEDIA UNREADABLE slate and the item is marked Unreadable with the decoder's reason (as for files that fail to open). Decodable frames, damaged frames and the sound are untouched. Co-Authored-By: Claude Opus 5.5 --- crates/codecs/src/video.rs | 26 ++++++- crates/engine/src/media_pool.rs | 127 ++++++++++++++++++++++++++++++-- 2 files changed, 144 insertions(+), 9 deletions(-) diff --git a/crates/codecs/src/video.rs b/crates/codecs/src/video.rs index 86b21b7d..fa8eb9de 100644 --- a/crates/codecs/src/video.rs +++ b/crates/codecs/src/video.rs @@ -224,7 +224,7 @@ impl H264Decoder { fn make_inner(avcc: &[u8], threads: usize) -> Result { let mut dec = filmcraft_h264::Decoder::with_threads(threads); if !avcc.is_empty() { - dec.configure_avcc(avcc).map_err(|e| CodecError::Decode(e.to_string()))?; + dec.configure_avcc(avcc).map_err(h264_error)?; } Ok(dec) } @@ -264,9 +264,19 @@ impl H264Decoder { } } +/// An H.264 decoder error as a [`CodecError`]: a feature the decoder does not implement (High +/// 4:2:2, field pictures...) is [`CodecError::Unsupported`], so the media pool shows the +/// unreadable slate instead of a black picture (#626); anything else is a decode error. +fn h264_error(e: filmcraft_h264::Error) -> CodecError { + match e { + filmcraft_h264::Error::Unsupported(what) => CodecError::Unsupported(format!("H.264: {what}")), + e => CodecError::Decode(e.to_string()), + } +} + impl VideoDecoder for H264Decoder { fn decode(&mut self, sample: &[u8], pts: i64) -> Result> { - let pics = self.dec.decode(sample, pts).map_err(|e| CodecError::Decode(e.to_string()))?; + let pics = self.dec.decode(sample, pts).map_err(h264_error)?; Ok(pics.into_iter().map(Self::convert).collect()) } fn flush(&mut self) -> Vec { @@ -328,7 +338,7 @@ pub struct HevcDecoder { impl HevcDecoder { pub fn new(hvcc: Vec) -> Result { - let dec = filmcraft_hevc::Decoder::from_hvcc(&hvcc).map_err(|e| CodecError::Decode(e.to_string()))?; + let dec = filmcraft_hevc::Decoder::from_hvcc(&hvcc).map_err(hevc_error)?; let (length_size, highest_tid) = hvcc_length_size_and_tid(&hvcc); Ok(Self { hvcc, dec, length_size, highest_tid }) } @@ -368,9 +378,17 @@ impl HevcDecoder { } } +/// An HEVC decoder error as a [`CodecError`] (see [`h264_error`]). +fn hevc_error(e: filmcraft_hevc::Error) -> CodecError { + match e { + filmcraft_hevc::Error::Unsupported(what) => CodecError::Unsupported(format!("HEVC: {what}")), + e => CodecError::Decode(e.to_string()), + } +} + impl VideoDecoder for HevcDecoder { fn decode(&mut self, sample: &[u8], pts: i64) -> Result> { - let pics = self.dec.decode(sample, pts).map_err(|e| CodecError::Decode(e.to_string()))?; + let pics = self.dec.decode(sample, pts).map_err(hevc_error)?; Ok(pics.into_iter().map(Self::convert).collect()) } fn flush(&mut self) -> Vec { diff --git a/crates/engine/src/media_pool.rs b/crates/engine/src/media_pool.rs index f82aa78b..ac849710 100644 --- a/crates/engine/src/media_pool.rs +++ b/crates/engine/src/media_pool.rs @@ -4,7 +4,9 @@ //! - Sources are cached per item **and per reference** (the file path, the offline flag), so a //! relink, Make Offline or their undo picks up the right file on the next frame. //! - Media that can't be opened (missing, unreadable) or was made offline renders the offline -//! slate ([`filmcraft_render::offline`]) instead of failing; the reason is kept for the UI. +//! slate ([`filmcraft_render::offline`]) instead of failing; the reason is kept for the UI. So +//! do the frames of a file that opens but whose picture FilmCraft cannot decode (a codec or +//! profile without a decoder). //! - With proxies enabled ([`MediaPool::set_use_proxies`]) items with an attached proxy are read //! from it. Proxy frames are smaller; the source still reports the full-resolution size, and the //! compositor derives its pixel scale from the frame it gets, so effects and Motion render the @@ -34,7 +36,7 @@ pub struct MediaPool { /// item → (reference key, source) sources: RwLock>, proxies: RwLock>, - offline: RwLock>, + offline: Arc>>, use_proxies: AtomicBool, /// Openers tried before the built-in ones (MP4/MOV + codecs register here). pub openers: RwLock>, @@ -46,7 +48,7 @@ impl Default for MediaPool { Self { sources: RwLock::new(HashMap::new()), proxies: RwLock::new(HashMap::new()), - offline: RwLock::new(HashMap::new()), + offline: Arc::new(RwLock::new(HashMap::new())), use_proxies: AtomicBool::new(false), openers: RwLock::new(filmcraft_codecs::openers()), } @@ -139,8 +141,16 @@ impl MediaPool { /// Cache a source opened from `path` for an item. pub fn insert_file(&self, item: ItemId, path: &str, src: SharedSource) { - self.sources.write().unwrap_or_else(|e| e.into_inner()).insert(item, (format!("file:{path}"), src)); self.offline.write().unwrap_or_else(|e| e.into_inner()).remove(&item); + let src = self.unsupported_as_slate(item, path, src); + self.sources.write().unwrap_or_else(|e| e.into_inner()).insert(item, (format!("file:{path}"), src)); + } + + /// `src` (opened from `path` for `item`) showing the unreadable slate for the frames it + /// cannot decode. + fn unsupported_as_slate(&self, item: ItemId, path: &str, src: SharedSource) -> SharedSource { + let slate = SlateSource::new(src.info().clone(), &file_name(path), OfflineReason::Unreadable); + Arc::new(UnsupportedAsSlate { inner: src, slate, item, path: path.to_string(), offline: self.offline.clone() }) } pub fn remove(&self, item: ItemId) { @@ -249,7 +259,7 @@ impl MediaPool { } { Ok(s) => { self.offline.write().unwrap_or_else(|e| e.into_inner()).remove(&item); - s + self.unsupported_as_slate(item, path, s) } Err(_) if filmcraft_media::pending::is_set() => { // the host is still fetching the file's bytes: show the slate for now, retry later @@ -373,6 +383,42 @@ impl MediaSource for SlateSource { } } +/// A file's source whose picture shows the unreadable slate for each frame its decoder reports as +/// unsupported (H.264 High 4:2:2, a codec without a decoder...), instead of no picture at all, +/// and marks the item unreadable with the reason, so the monitors and the Project panel say why +/// (#626). Its sound plays as before. +struct UnsupportedAsSlate { + inner: SharedSource, + slate: SlateSource, + item: ItemId, + path: String, + offline: Arc>>, +} + +impl MediaSource for UnsupportedAsSlate { + fn info(&self) -> &MediaInfo { + self.inner.info() + } + fn video_frame(&self, req: FrameRequest) -> filmcraft_media::Result> { + match self.inner.video_frame(req) { + Err(MediaError::Unsupported(why)) => { + self.offline.write().unwrap_or_else(|e| e.into_inner()).entry(self.item).or_insert_with(|| { + log::warn!("media unreadable: {}: {why}", self.path); + OfflineStatus { reason: OfflineReason::Unreadable, path: self.path.clone(), error: why } + }); + self.slate.video_frame(req) + } + r => r, + } + } + fn audio(&self, start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result { + self.inner.audio(start, frames, sample_rate) + } + fn audio_stream(&self, stream: usize, start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result { + self.inner.audio_stream(stream, start, frames, sample_rate) + } +} + /// A proxy standing in for its full-resolution media: reports the full-resolution info and hands /// out the proxy's (smaller) frames. A proxy whose aspect ratio differs from the original is /// resampled to the original's aspect so it lines up exactly. @@ -501,4 +547,75 @@ mod tests { assert!(matches!(MediaPool::default().open_file(&missing, &crate::FsServices), Err(MediaError::Offline(_)))); std::fs::remove_dir_all(&dir).unwrap(); } + + /// A file whose first frame decodes and whose later frames need a decoder FilmCraft lacks. + struct PartlyDecodable(MediaInfo); + impl MediaSource for PartlyDecodable { + fn info(&self) -> &MediaInfo { + &self.0 + } + fn video_frame(&self, req: FrameRequest) -> filmcraft_media::Result> { + match req.time.0 { + 0 => Ok(Arc::new(VideoFrame::rgba8(2, 2, vec![7; 16]))), + 1 => Err(MediaError::Decode("damaged slice".into())), + _ => Err(MediaError::Unsupported("H.264: field pictures".into())), + } + } + fn audio(&self, _start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result { + Ok(AudioBuffer::silence(sample_rate, 3, frames)) + } + } + + /// #626: frames the decoder does not support show the unreadable slate (not nothing) and the + /// item says why; decodable frames, damaged ones and the sound are left alone. + #[test] + fn undecodable_frames_show_the_unreadable_slate() { + let info = GeneratorSource::new(filmcraft_media::Generator::ColorMatte { color: [0.0; 4] }, 16, 8, Default::default(), filmcraft_time::Tick(100)) + .info() + .clone(); + let pool = MediaPool::default(); + let item = ItemId(1); + pool.insert_file(item, "/media/cam/a.mov", Arc::new(PartlyDecodable(info))); + let src = pool.cached(item).unwrap(); + let at = |t| src.video_frame(FrameRequest { time: filmcraft_time::Tick(t), scale: 1.0 }); + assert_eq!(at(0).unwrap().to_rgba8(), vec![7; 16]); + assert!(matches!(at(1), Err(MediaError::Decode(_)))); + assert!(pool.offline_status(item).is_none(), "nothing unsupported yet"); + let f = at(2).unwrap(); + assert_eq!((f.width, f.height), (16, 8)); + assert_eq!(f.to_rgba8(), filmcraft_render::offline::slate_rgba8(16, 8, "a.mov", OfflineReason::Unreadable)); + let st = pool.offline_status(item).unwrap(); + assert_eq!((st.reason, st.path.as_str(), st.error.as_str()), (OfflineReason::Unreadable, "/media/cam/a.mov", "H.264: field pictures")); + assert_eq!(src.audio(0, 4, 48_000).unwrap().channels.len(), 3); + // linking the item to another file forgets the reason + pool.insert_file(item, "/media/cam/b.mov", src.clone()); + assert!(pool.offline_status(item).is_none()); + } + + /// #626 end to end: an H.264 profile FilmCraft does not decode yet (4:4:4) imports and shows + /// the unreadable slate with the decoder's reason, instead of a black picture. + #[test] + fn ffmpeg_unsupported_h264_profile_shows_the_unreadable_slate() { + let Some(ffmpeg) = filmcraft_testkit::oracle::ffmpeg_or_skip("media_pool") else { return }; + let path = filmcraft_testkit::fixtures_dir("engine/media_pool").join("h264_444_64x32.mp4"); + let Some(path) = filmcraft_testkit::fixtures::generate(&path, |tmp| { + std::process::Command::new(&ffmpeg) + .args(["-y", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc2=size=64x32:rate=24:duration=0.5"]) + .args(["-c:v", "libx264", "-preset", "fast", "-pix_fmt", "yuv444p"]) + .arg(tmp) + .status() + .is_ok_and(|s| s.success()) + }) else { + return; + }; + let mut s = crate::Session::default(); + let r = s.execute("file.import", serde_json::json!({"paths": [path.to_string_lossy()]})).unwrap(); + let item = ItemId(r["items"][0].as_u64().unwrap()); + let src = s.media.source_for(&s.project, item, &*s.services).unwrap(); + let f = src.video_frame(FrameRequest { time: filmcraft_time::Tick::ZERO, scale: 1.0 }).unwrap(); + assert_eq!(f.to_rgba8(), filmcraft_render::offline::slate_rgba8(64, 32, "h264_444_64x32.mp4", OfflineReason::Unreadable)); + let st = s.media.offline_status(item).unwrap(); + assert_eq!(st.reason, OfflineReason::Unreadable); + assert!(st.error.contains("H.264") && st.error.contains("chroma_format_idc 3"), "{}", st.error); + } } From 8f1b7c3494265a03a8b025fb692599d121690618 Mon Sep 17 00:00:00 2001 From: ReReddo <249398616+ReReddo@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:45:20 +0300 Subject: [PATCH 116/144] program monitor: drag a rectangle's corners round (#744) A rectangle's rounded corners could only be set by typing a Corner Radius in Properties. As in Premiere, a selected rectangle now shows four small circles just inside its corners: dragging one towards the middle rounds all four corners (Corner Radius is one value), dragging it back out squares them, and the radius stops at half the shorter side, where the renderer caps it too. The circles sit on each corner's diagonal at the radius, at least 12 points in, and show only on rectangles at least four times that across on screen. While dragging, a white outline of the rounded rectangle previews the result; the radius is set on release with graphics.set, one undo step per drag. Automation ids: program.layer...corner.. Tests: graphics_handles_ui (the circles round all four corners, clamp and undo; ellipses and tiny rectangles have none). --- crates/ui-egui/src/panels/graphics.rs | 97 ++++++++++++++++++++- crates/ui-egui/tests/graphics_handles_ui.rs | 56 +++++++++++- docs/graphics.md | 2 +- 3 files changed, 151 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels/graphics.rs b/crates/ui-egui/src/panels/graphics.rs index 3060810a..7cf5f47b 100644 --- a/crates/ui-egui/src/panels/graphics.rs +++ b/crates/ui-egui/src/panels/graphics.rs @@ -11,12 +11,14 @@ //! - **Selection tool:** click selects a layer (bounding box with handles and anchor point); drag //! moves it; drag the anchor point to move that alone. What a handle does depends on the layer //! (see [`HandleDrag`]): point text scales about its anchor point, a paragraph-text box is -//! resized and its text re-wraps, a shape stretches away from its opposite side. +//! resized and its text re-wraps, a shape stretches away from its opposite side. A rectangle +//! also has four small circles just inside its corners: dragging one rounds all four corners. //! - **Rectangle / Ellipse tools:** drag out a shape. **Pen tool:** click points; click the first //! point again (or press Return) to close the path; Esc cancels. //! //! All edits go through `graphics.*` commands. Automation ids: `program.layer..`, //! `program.layer...handle.`, `program.layer...anchor`, +//! `program.layer...corner.`, //! `program.textEdit`, `graphics.*` in the panels. use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; @@ -164,6 +166,8 @@ enum DragKind { Handle(usize), /// The layer's anchor point: it moves, the layer stays where it is. Anchor, + /// Corner-radius handle `n` of a rectangle (see [`corner_handles`]). + Corner(usize), NewShape, /// The Type tool dragging out the box of a new paragraph-text layer. NewTextBox, @@ -405,6 +409,61 @@ fn is_paragraph(t: &filmcraft_project::graphic::TextProps) -> bool { t.box_width > 0.0 && !t.vertical } +/// Corner-radius handles sit at least this far (in points) inside a rectangle's corners, and show +/// only on a rectangle at least four times as wide and tall on screen. +const CORNER_INSET: f32 = 12.0; + +/// A rectangle layer's corner radius as it is drawn (at most half its shorter side) and its four +/// corner-radius handles on screen (TL, TR, BR, BL); `None` for other layers and for a rectangle +/// too small on screen to hold them. +fn corner_handles(v: &LayerView) -> Option<(f32, [Pos2; 4])> { + let LayerContent::Shape(s) = &v.spec.content else { return None }; + let q = v.quad(); + if s.shape != 0 || (q[1] - q[0]).length() < 4.0 * CORNER_INSET || (q[3] - q[0]).length() < 4.0 * CORNER_INSET { + return None; + } + let r = s.corner_radius.min(s.size.0 / 2.0).min(s.size.1 / 2.0).max(0.0); + Some((r, corner_points(v, r))) +} + +/// Where the corner-radius handles of rectangle `v` sit for radius `r`: on each corner's diagonal, +/// where the rounded corner's arc is centred, but at least [`CORNER_INSET`] points in. +fn corner_points(v: &LayerView, r: f32) -> [Pos2; 4] { + let b = v.local; + let d = r.max(CORNER_INSET / screen_scale(&v.to_screen).max(1e-6)).min((b[2] - b[0]) / 2.0).min((b[3] - b[1]) / 2.0); + [(b[0] + d, b[1] + d), (b[2] - d, b[1] + d), (b[2] - d, b[3] - d), (b[0] + d, b[3] - d)].map(|(x, y)| sp(&v.to_screen, x, y)) +} + +/// The corner radius after dragging handle `n` of rectangle `v` (radius `r0`) from `start` to +/// `cur`: the pointer's travel into the shape along the corner's diagonal, up to half the shorter +/// side. +fn corner_drag(v: &LayerView, r0: f32, n: usize, start: Pos2, cur: Pos2) -> f32 { + let (Some(a), Some(b)) = (v.to_local(start), v.to_local(cur)) else { return r0 }; + let (sx, sy) = match n { + 0 => (1.0, 1.0), + 1 => (-1.0, 1.0), + 2 => (-1.0, -1.0), + _ => (1.0, -1.0), + }; + let inward = ((b.0 - a.0) * sx + (b.1 - a.1) * sy) / 2.0; + let half = ((v.local[2] - v.local[0]).min(v.local[3] - v.local[1]) / 2.0).max(0.0); + (r0 + inward).clamp(0.0, half) +} + +/// The outline of rectangle `v` with corner radius `r`, on screen. +fn round_rect_outline(v: &LayerView, r: f32) -> Vec { + let b = v.local; + let mut pts = Vec::new(); + // each corner's arc, clockwise from the top-right + for (cx, cy, from) in [(b[2] - r, b[1] + r, -90.0_f32), (b[2] - r, b[3] - r, 0.0), (b[0] + r, b[3] - r, 90.0), (b[0] + r, b[1] + r, 180.0)] { + for i in 0..=8 { + let a = (from + 90.0 * i as f32 / 8.0).to_radians(); + pts.push(sp(&v.to_screen, cx + r * a.cos(), cy + r * a.sin())); + } + } + pts +} + /// The layer's anchor point on screen. fn anchor_screen(v: &LayerView) -> Pos2 { sp(&v.to_screen, v.spec.transform.anchor.x as f32, v.spec.transform.anchor.y as f32) @@ -506,6 +565,12 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra painter.rect_filled(hr, 0.0, Color32::WHITE); app.auto.add(&format!("program.layer.{}.{}.handle.{n}", v.clip.0, v.layer), hr.expand(4.0), label); } + // a rectangle's corner-radius handles, just inside its corners + for (n, c) in corner_handles(v).map(|(_, pts)| pts).iter().flatten().enumerate() { + painter.circle_filled(*c, 3.0, Color32::WHITE); + painter.circle_stroke(*c, 3.0, Stroke::new(1.0, accent)); + app.auto.add(&format!("program.layer.{}.{}.corner.{n}", v.clip.0, v.layer), Rect::from_center_size(*c, vec2(12.0, 12.0)), "corner radius"); + } // anchor point let a = anchor_screen(v); painter.circle_stroke(a, 4.0, Stroke::new(1.0, accent)); @@ -528,8 +593,15 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra Tool::Type | Tool::VerticalType => ui.ctx().set_cursor_icon(egui::CursorIcon::Text), Tool::Rectangle | Tool::Ellipse | Tool::Pen => ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair), Tool::Selection => { + let corner = views + .iter() + .filter(|v| Some(v.clip) == sel_clip && sel_layers.contains(&v.layer)) + .find_map(|v| corner_handles(v).and_then(|(_, pts)| pts.iter().position(|c| (*c - p).length() <= 6.0))); if editing.as_ref().is_some_and(|e| views.iter().any(|v| v.clip.0 == e.clip && v.layer == e.layer && v.hit(p))) { ui.ctx().set_cursor_icon(egui::CursorIcon::Text); + } else if let Some(n) = corner { + // along the corner's diagonal, the way the drag works + ui.ctx().set_cursor_icon(if n % 2 == 0 { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw }); } else if views.iter().any(|v| v.hit(p)) { ui.ctx().set_cursor_icon(egui::CursorIcon::Move); } @@ -576,10 +648,14 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra let selected = || views.iter().filter(|v| Some(v.clip) == sel_clip && sel_layers.contains(&v.layer)); let anchor = selected().find(|v| tool == Tool::Selection && (anchor_screen(v) - p).length() <= 7.0); let handle = selected().find_map(|v| handle_points(&v.quad()).iter().position(|c| (*c - p).length() <= 7.0).map(|n| (v.clip, v.layer, n))); + let corner = selected() + .find_map(|v| corner_handles(v).and_then(|(_, pts)| pts.iter().position(|c| (*c - p).length() <= 6.0)).map(|n| (v.clip, v.layer, n))); if let Some(v) = anchor { drag = Some(DragState { kind: DragKind::Anchor, clip: v.clip, layer: v.layer, start: p }); } else if let Some((c, l, n)) = handle { drag = Some(DragState { kind: DragKind::Handle(n), clip: c, layer: l, start: p }); + } else if let Some((c, l, n)) = corner { + drag = Some(DragState { kind: DragKind::Corner(n), clip: c, layer: l, start: p }); } else if tool == Tool::Type && !views.iter().any(|v| v.is_text() && v.hit(p)) { drag = Some(DragState { kind: DragKind::NewTextBox, clip: ClipId(0), layer: 0, start: p }); } else if tool == Tool::Selection @@ -641,6 +717,17 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra painter.line_segment([a - vec2(0.0, 6.0), a + vec2(0.0, 6.0)], Stroke::new(1.0, Color32::WHITE)); } } + DragKind::Corner(n) => { + if let Some(v) = v + && let Some((r0, _)) = corner_handles(v) + { + let r = corner_drag(v, r0, n, d.start, cur); + painter.add(egui::Shape::closed_line(round_rect_outline(v, r), Stroke::new(1.0, Color32::WHITE))); + for c in corner_points(v, r) { + painter.circle_stroke(c, 3.0, Stroke::new(1.0, Color32::WHITE)); + } + } + } DragKind::NewTextBox => { painter.rect_stroke(Rect::from_two_pos(d.start, cur), 0.0, Stroke::new(1.0, t.danger), StrokeKind::Middle); } @@ -697,6 +784,14 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra )); } } + DragKind::Corner(n) if moved => { + if let Some(v) = v + && let Some((r0, _)) = corner_handles(v) + { + let r = corner_drag(v, r0, n, d.start, cur); + actions.push(("graphics.set".into(), json!({"clip": v.clip.0, "layer": v.layer, "props": {"corner_radius": r}}))); + } + } DragKind::NewTextBox if moved => { end_edit(app, ui); actions.push(new_text_action(app, pic, frame, d.start, Some(cur))); diff --git a/crates/ui-egui/tests/graphics_handles_ui.rs b/crates/ui-egui/tests/graphics_handles_ui.rs index b7553926..cf392822 100644 --- a/crates/ui-egui/tests/graphics_handles_ui.rs +++ b/crates/ui-egui/tests/graphics_handles_ui.rs @@ -432,6 +432,58 @@ fn polygon_handles_keep_the_opposite_corner() { assert!(near(d.handle(&pre, 2), (br.0 + 50.0, br.1 + 40.0)), "bottom-right follows the pointer"); } +/// The Corner Radius of the shape layer of graphic clip `clip`. +fn corner_radius(d: &mut Driver, clip: u64) -> f64 { + let s = &d.harness.state().session; + let (_, it) = s.active_sequence().unwrap().find_item(filmcraft_project::ClipId(clip)).unwrap(); + let e = it.effects.iter().find(|e| e.effect == "graphic_shape").unwrap(); + e.param("corner_radius").unwrap().value_at(filmcraft_time::Tick::ZERO).as_f64().unwrap() +} + +#[test] +fn a_rectangles_corner_circles_round_all_its_corners() { + let mut d = Driver::new(); + let pre = d.shape(); + let clip: u64 = pre.split('.').nth(2).unwrap().parse().unwrap(); + let (tl, br) = (d.handle(&pre, 0), d.handle(&pre, 2)); + let k = (br.0 - tl.0) / 500.0; // screen points per layer pixel + let corners: Vec<(f64, f64)> = (0..4).map(|n| d.at(&pre, &format!("corner.{n}"))).collect(); + // just inside each corner, on its diagonal + assert!(corners[0].0 > tl.0 + 5.0 && corners[0].1 > tl.1 + 5.0 && corners[2].0 < br.0 - 5.0 && corners[2].1 < br.1 - 5.0, "{corners:?}"); + + // the top-left circle dragged 40 points down and right: a radius of 40 points' travel + d.drag(corners[0], (40.0, 40.0)); + let r = corner_radius(&mut d, clip); + assert!((r - 40.0 / k).abs() < 2.0, "radius {r}, expected {}", 40.0 / k); + // the circles moved in to the radius, all four alike + let (tl2, br2) = (d.handle(&pre, 0), d.handle(&pre, 2)); + assert!(near(tl, tl2) && near(br, br2), "the box stays"); + let c0 = d.at(&pre, "corner.0"); + let c2 = d.at(&pre, "corner.2"); + assert!(near(c0, (tl.0 + r * k, tl.1 + r * k)) && near(c2, (br.0 - r * k, br.1 - r * k)), "{c0:?} {c2:?}"); + + // any circle drags all four; past the middle it stops at half the shorter side + d.drag(c2, (-300.0, -300.0)); + assert_eq!(corner_radius(&mut d, clip), 150.0); + // back out squares them; one undo step per drag + let c1 = d.at(&pre, "corner.1"); + d.drag(c1, (200.0, -200.0)); + assert_eq!(corner_radius(&mut d, clip), 0.0); + d.exec("edit.undo", json!({})); + assert_eq!(corner_radius(&mut d, clip), 150.0); +} + +#[test] +fn only_rectangles_big_enough_get_corner_circles() { + let mut d = Driver::new(); + let r = d.exec("graphics.newShape", json!({"shape": "ellipse", "position": [800, 500], "size": [500, 300]})); + let pre = d.select(r["clip"].as_u64().unwrap()); + assert!(d.ok("ui.elements", json!({"prefix": format!("{pre}corner")})).as_array().unwrap().is_empty(), "an ellipse has none"); + let r = d.exec("graphics.newShape", json!({"shape": "rectangle", "position": [800, 500], "size": [20, 20]})); + let pre = d.select(r["clip"].as_u64().unwrap()); + assert!(d.ok("ui.elements", json!({"prefix": format!("{pre}corner")})).as_array().unwrap().is_empty(), "a tiny rectangle has none"); +} + #[test] fn shape_edge_handles_stretch_one_axis() { let mut d = Driver::new(); @@ -466,7 +518,7 @@ fn a_hidden_layer_has_no_box_and_cannot_be_clicked() { let v = d.ok("ui.elements", json!({"prefix": layer})); v.as_array().unwrap().iter().map(|e| e["id"].as_str().unwrap().to_string()).collect() }; - assert_eq!(ids(&mut d).len(), 10, "the layer, its eight handles and its anchor: {:?}", ids(&mut d)); + assert_eq!(ids(&mut d).len(), 14, "the layer, its eight handles, four corner circles and its anchor: {:?}", ids(&mut d)); let r = d.rect(&layer); let inside = (r[0] + r[2] / 4.0, r[1] + r[3] / 2.0); @@ -485,5 +537,5 @@ fn a_hidden_layer_has_no_box_and_cannot_be_clicked() { // (elsewhere on it, so that the two clicks are not a double click) d.ok("ui.click", json!({"x": r[0] + r[2] * 0.75, "y": inside.1})); d.frames(4); - assert_eq!(ids(&mut d).len(), 10); + assert_eq!(ids(&mut d).len(), 14); } diff --git a/docs/graphics.md b/docs/graphics.md index fd04667d..e19dcbbd 100644 --- a/docs/graphics.md +++ b/docs/graphics.md @@ -24,7 +24,7 @@ kerning and ligatures, bidi, line breaking, all drawn as vectors in linear light | Group | Properties (parameter ids) | |---|---| | Text | `text` (Source Text), `font`, `font_style`, `size` (px), `align` (Left/Center/Right/Justify), `tracking` (1/1000 em), `kerning`, `ligatures`, `leading` (px added to the natural line height), `baseline_shift`, `faux_bold`, `faux_italic`, `caps` (Normal/All Caps/Small Caps), `underline`, `box_width` (0 = point text, else paragraph text wrapped at that width), `box_height` (paragraph text: lines that do not fit are not shown; 0 = as tall as the text) | -| Shape | `shape` (Rectangle/Ellipse/Polygon/Path), `size` [w, h], `sides`, `corner_radius`, `points` (path vertices relative to the layer origin) | +| Shape | `shape` (Rectangle/Ellipse/Polygon/Path), `size` [w, h], `sides`, `corner_radius` (one radius for all four corners, at most half the shorter side; on the Program monitor, drag one of the four small circles just inside a selected rectangle's corners), `points` (path vertices relative to the layer origin) | | Appearance | `fill`, `fill_color`; `stroke`, `stroke_color`, `stroke_width`, `stroke_type` (Outer/Center/Inner) and the same for `stroke2`; `background`, `background_color`, `background_opacity`, `background_size` (padding), `background_radius`; `shadow`, `shadow_color`, `shadow_opacity`, `shadow_angle` (135° = down-right), `shadow_distance`, `shadow_size`, `shadow_blur` | | Transform | `position` (graphic canvas px), `anchor` (layer px), `scale`, `scale_width`, `uniform_scale`, `rotation`, `opacity` | From 3190eab4efc96f53f8ce1ef00f049076841f07ef Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 21:55:11 +0200 Subject: [PATCH 117/144] #313: the Source monitor remembers its clips; Close and Close All Problem: the Source monitor only knew the one clip it showed. Opening another clip forgot the previous one, there was no list to switch back from, and no way to close a Source clip (Premiere keeps a list of the recently opened clips with Close / Close All). Fix: EditorState keeps `source_history` (most recent first, at most 20), updated after every command; items removed from the project drop out. New commands `source.close` (the next most recent clip opens in its place) and `source.closeAll`. The Source panel menu lists the recent clips (the shown one ticked) to switch between, then Close / Close All. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands.rs | 3 ++ crates/engine/src/lib.rs | 8 ++++ crates/engine/src/source_monitor.rs | 69 ++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/zh-cn.tsv | 1 + crates/ui-egui/src/panels/mod.rs | 4 ++ crates/ui-egui/src/panels/monitor.rs | 34 ++++++++++++++ 8 files changed, 121 insertions(+) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index c1f2f56d..d56a03b3 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -2081,6 +2081,9 @@ fn build() -> Vec { s.events.push(crate::Event::OpenSource(id)); Ok(Value::Null) }), + // Source panel: Close / Close All, for the clip shown and the list of recent clips (#313) + cmd!("source.close", "Close", [], None, "{}", crate::source_monitor::has_source_clip, |s, _| crate::source_monitor::close(s, false)), + cmd!("source.closeAll", "Close All", [], None, "{}", crate::source_monitor::has_source_clip, |s, _| crate::source_monitor::close(s, true)), cmd!("source.setPlayhead", "Set Source Playhead", [], None, r#"{"time":ticks|"frame":i64|"seconds":f64}"#, always, |s, p| { let item = s.state.source_item.ok_or_else(|| EngineError::Other("no source clip".into()))?; let view = crate::clip_ops::source_view(s, item); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index fe6f94be..01166df8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -231,6 +231,10 @@ pub struct EditorState { /// Item loaded in the Source monitor and its playhead (media time). pub source_item: Option, pub source_playhead: Tick, + /// Clips opened in the Source monitor, most recent first: the Source panel's list of recent + /// clips (#313). Kept up to date after every command; items gone from the project drop out. + #[serde(default)] + pub source_history: Vec, /// Selected timeline items. pub selection: Vec, /// Selected project panel items. @@ -646,6 +650,9 @@ impl Session { let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, ¶ms))) .unwrap_or_else(|_| Err(EngineError::Other(format!("internal error in `{id}` (see the crash log); the command did not complete")))); self.exec_depth -= 1; + if self.exec_depth == 0 { + source_monitor::track_history(self); + } // source graphics: an edited instance updates the shared layers and the other instances if r.is_ok() && spec.journal && id.starts_with("graphics.") && !self.project.source_graphics.is_empty() { graphic_templates::sync_source_graphics(self); @@ -881,6 +888,7 @@ impl Session { { self.state.source_item = None; } + source_monitor::track_history(self); if let Some(seq) = self.state.active_sequence.and_then(|s| p.sequence(s)) { self.state.selection.retain(|c| seq.find_item(*c).is_some()); self.state.caption_selection.retain(|c| seq.find_caption(*c).is_some()); diff --git a/crates/engine/src/source_monitor.rs b/crates/engine/src/source_monitor.rs index cdbcf35f..5c1de07f 100644 --- a/crates/engine/src/source_monitor.rs +++ b/crates/engine/src/source_monitor.rs @@ -90,6 +90,52 @@ pub fn source_command(id: &str) -> bool { ) } +/// Most clips the Source monitor's list of recent clips keeps. +pub const SOURCE_HISTORY_MAX: usize = 20; + +/// Keep the Source monitor's recent clips in step with the clip it shows: the open clip moves to +/// the front, items no longer in the project drop out. +pub(crate) fn track_history(s: &mut Session) { + let current = s.state.source_item; + let project = &s.project; + let history = &mut s.state.source_history; + history.retain(|i| project.item(*i).is_some()); + if let Some(cur) = current.filter(|i| project.item(*i).is_some()) + && history.first() != Some(&cur) + { + history.retain(|i| *i != cur); + history.insert(0, cur); + } + history.truncate(SOURCE_HISTORY_MAX); +} + +/// Enablement of Close / Close All: a clip is open in the Source monitor. +pub(crate) fn has_source_clip(s: &Session) -> std::result::Result<(), String> { + s.state.source_item.map(|_| ()).ok_or_else(|| "no clip is open in the Source monitor".into()) +} + +/// Source panel ▸ Close (`all`: Close All). The open clip leaves the Source monitor and its list +/// of recent clips, and the next most recent one opens in its place, as in Premiere; Close All +/// empties the list and the monitor. +pub(crate) fn close(s: &mut Session, all: bool) -> Result { + if all { + s.state.source_history.clear(); + } else if let Some(cur) = s.state.source_item { + s.state.source_history.retain(|i| *i != cur); + } + let next = s.state.source_history.iter().copied().find(|i| s.project.item(*i).is_some()); + match next { + Some(i) => { + s.execute("source.open", json!({"item": i.0}))?; + } + None => { + s.state.source_item = None; + s.state.source_playhead = Tick::ZERO; + } + } + Ok(json!({"item": next.map(|i| i.0)})) +} + fn marker_list(p: &mut Project, item: ItemId) -> Result<&mut Vec> { let it = p.item_mut(item).ok_or_else(|| EngineError::Other("Source item is unavailable".into()))?; match &mut it.kind { @@ -288,6 +334,29 @@ mod tests { s.execute("markers.clearCurrent", json!({"target":"source"})).unwrap(); assert!(source_view(&s, ItemId(5)).unwrap().markers.is_empty()); } + /// #313: the Source monitor remembers the clips opened in it, most recent first; Close shows + /// the next most recent one and Close All empties the monitor. + #[test] + fn source_clips_are_remembered_and_closed_in_turn() { + let mut s = fixture(); + let a = ItemId(5); + let mut others: Vec = s.project.items.values().filter(|i| i.as_media().is_some() && i.id != a).map(|i| i.id).collect(); + others.sort(); + let [b, c] = [others[0], others[1]]; + for i in [b, c, a] { + s.execute("source.open", json!({"item": i.0})).unwrap(); + } + assert_eq!(s.state.source_history, [a, c, b]); + assert_eq!(s.execute("source.close", json!({})).unwrap()["item"], c.0); + assert_eq!((s.state.source_item, s.state.source_history.clone()), (Some(c), vec![c, b])); + // an item deleted from the project leaves the list + s.execute("project.delete", json!({"items": [b.0]})).unwrap(); + assert!(s.project.item(b).is_none()); + assert!(!s.state.source_history.contains(&b)); + s.execute("source.closeAll", json!({})).unwrap(); + assert_eq!((s.state.source_item, s.state.source_history.len()), (None, 0)); + assert!(s.execute("source.close", json!({})).is_err(), "nothing left to close"); + } #[test] fn invalid_marker_parameters_do_not_edit_the_project() { let mut s = fixture(); diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 3f45ce9d..2af7e2a2 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -496,6 +496,7 @@ {panel} — coming in a later milestone {panel}: llegará en una versión posterior Close Panel Cerrar panel Close Other Timeline Panels Cerrar los demás paneles de línea de tiempo + Close All Cerrar todo Maximize Frame Maximizar marco Restore Workspace Restaurar espacio de trabajo Reveal Sequence in Project Mostrar secuencia en el proyecto diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 3d54f873..d3289309 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -494,6 +494,7 @@ {panel} — coming in a later milestone {panel} — 今後のマイルストーンで実装予定 Close Panel パネルを閉じる Close Other Timeline Panels 他のタイムラインパネルを閉じる + Close All すべて閉じる Maximize Frame パネルを最大化 Restore Workspace ワークスペースを復元 Reveal Sequence in Project プロジェクトでシーケンスを表示 diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index e504e341..e161fd52 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -487,6 +487,7 @@ {panel} — coming in a later milestone {panel} — 将在后续里程碑中推出 Close Panel 关闭面板 Close Other Timeline Panels 关闭其他时间轴面板 + Close All 全部关闭 Maximize Frame 最大化帧 Restore Workspace 恢复工作区 Reveal Sequence in Project 在项目面板中显示序列 diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index bcb386fb..66d0d475 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -272,6 +272,10 @@ pub fn panel_menu_popup(app: &mut FilmcraftApp, ui: &mut egui::Ui) { ui.separator(); close |= media_browser::panel_menu(app, ui); } + if p == PanelKind::Source { + ui.separator(); + close |= monitor::source_panel_menu(app, ui); + } }); }); // A click elsewhere or Escape closes the menu. The click that opened it is over the tab, not diff --git a/crates/ui-egui/src/panels/monitor.rs b/crates/ui-egui/src/panels/monitor.rs index d94867f3..a57d0483 100644 --- a/crates/ui-egui/src/panels/monitor.rs +++ b/crates/ui-egui/src/panels/monitor.rs @@ -822,6 +822,40 @@ fn transport(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect, which: Which) } } +/// The Source panel menu's clips (#313): the recently opened clips, the shown one ticked, to +/// switch between, then Close / Close All. Returns whether the menu is done. +pub fn source_panel_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui) -> bool { + let mut done = false; + let current = app.session.state.source_item; + let recent: Vec<(u64, String)> = + app.session.state.source_history.iter().filter_map(|i| app.session.project.item(*i).map(|it| (i.0, it.name.clone()))).collect(); + for (id, name) in &recent { + let shown = current.is_some_and(|c| c.0 == *id); + let r = ui.button(if shown { format!("✓ {name}") } else { name.clone() }); + app.auto.add(&format!("panel.menu.Source.recent.{id}"), r.rect, name); + if r.clicked() { + if !shown && let Err(e) = app.session.execute("source.open", json!({"item": id})) { + app.ui.status = e.to_string(); + } + done = true; + } + } + if !recent.is_empty() { + ui.separator(); + } + for (cmd, id, label) in [("source.close", "close", tl!("Close")), ("source.closeAll", "closeAll", tl!("Close All"))] { + let r = ui.add_enabled(current.is_some(), egui::Button::new(label)); + app.auto.add(&format!("panel.menu.Source.{id}"), r.rect, label); + if r.clicked() { + if let Err(e) = app.session.execute(cmd, json!({})) { + app.ui.status = e.to_string(); + } + done = true; + } + } + done +} + fn source_nav(app: &mut FilmcraftApp, cmd: &str) { let Some(item) = app.session.state.source_item else { return }; let Some(v) = filmcraft_engine::clip_ops::source_view(&app.session, item) else { return }; From ad8080159632b28a9be0725d6d2f39692c61c200 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 22:01:54 +0200 Subject: [PATCH 118/144] #313: double-clicking a Timeline clip opens it in the Source monitor Problem: the Timeline only reacted to double-clicks on transitions and nested sequences, so double-clicking an ordinary clip did nothing instead of Premiere's opening it in the Source monitor. Fix: `source.open` takes an optional `clip` (a clip of the active sequence): its media opens in the Source monitor, parked on the frame under the playhead when the playhead is over the clip, otherwise on the clip's first frame. The Timeline's Selection-tool double-click on a clip body (not its edge, not a nested sequence or graphic) dispatches it. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands.rs | 5 ++- crates/engine/src/source_monitor.rs | 47 ++++++++++++++++++++++++++- crates/ui-egui/src/panels/timeline.rs | 15 +++++++++ 3 files changed, 65 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index d5618b99..31645e87 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -2118,7 +2118,10 @@ fn build() -> Vec { Ok(Value::Null) }), // ================= Source monitor ================= - cmd!("source.open", "Open in Source Monitor", [], None, r#"{"item":id?}"#, always, |s, p| { + cmd!("source.open", "Open in Source Monitor", [], None, r#"{"item":id?,"clip":id?}"#, always, |s, p| { + if let Some(clip) = clip_p(p, "clip") { + return crate::source_monitor::open_clip(s, clip); + } let id = item_p(p, "item") .or(s.state.project_selection.first().copied()) .ok_or_else(|| bad("source.open", "need `item` (or a Project panel selection)"))?; diff --git a/crates/engine/src/source_monitor.rs b/crates/engine/src/source_monitor.rs index cdbcf35f..84790a52 100644 --- a/crates/engine/src/source_monitor.rs +++ b/crates/engine/src/source_monitor.rs @@ -3,7 +3,7 @@ use crate::clip_ops::source_view; use crate::commands::bad; use crate::{EngineError, Result, Session}; -use filmcraft_project::{ItemId, Label, Marker, MarkerId, MarkerKind, Project}; +use filmcraft_project::{ClipId, ItemId, ItemKind, Label, Marker, MarkerId, MarkerKind, Project}; use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick, TimeRange}; use serde_json::{Value, json}; @@ -203,6 +203,27 @@ pub(crate) fn route(s: &mut Session, id: &str, p: &Value) -> Result Result { + let id = "source.open"; + let seq = s.active_sequence().ok_or(EngineError::NoSequence)?; + let (_, it) = seq.find_item(clip).ok_or_else(|| bad(id, "no such clip in the active sequence"))?; + let t = s.playhead(); + let time = it.source_time_at(if t >= it.start && t < it.end() { t } else { it.start }); + let item = it.item; + match s.project.item(item).map(|i| &i.kind) { + None => return Err(bad(id, "the clip's source is unavailable")), + Some(ItemKind::Graphic { .. }) => return Err(bad(id, "a graphic clip has no source to open")), + Some(_) => {} + } + s.execute(id, json!({"item": item.0}))?; + // source.setPlayhead snaps to the source frame grid and keeps the playhead inside a subclip + s.execute("source.setPlayhead", json!({"time": time.0}))?; + Ok(json!({"item": item.0, "time": s.state.source_playhead.0})) +} + fn text<'a>(p: &'a Value, key: &str, id: &str) -> Result> { p.get(key).map(|v| v.as_str().ok_or_else(|| bad(id, format!("{key} must be text")))).transpose() } @@ -289,6 +310,30 @@ mod tests { assert!(source_view(&s, ItemId(5)).unwrap().markers.is_empty()); } #[test] + fn timeline_clip_opens_in_source_at_the_matching_frame() { + let mut s = fixture(); + let seq = s.active_sequence().unwrap(); + let it = seq + .all_tracks() + .flat_map(|t| t.items.iter()) + .find(|i| i.source_in > Tick::ZERO && s.project.item(i.item).is_some_and(|p| p.as_media().is_some())) + .unwrap() + .clone(); + let rate = s.sequence_rate(); + let inside = it.start + rate.tick_of(3); + s.set_playhead(inside); + let r = s.execute("source.open", json!({"clip": it.id.0})).unwrap(); + assert_eq!(r["item"], it.item.0); + assert_eq!(s.state.source_item, Some(it.item)); + let view = source_view(&s, it.item).unwrap(); + assert_eq!(s.state.source_playhead, view.rate.snap(it.source_time_at(inside))); + // playhead off the clip: the clip's first frame + s.set_playhead(it.end() + rate.tick_of(1)); + s.execute("source.open", json!({"clip": it.id.0})).unwrap(); + assert_eq!(s.state.source_playhead, view.rate.snap(it.source_in)); + assert!(s.execute("source.open", json!({"clip": u64::MAX})).is_err()); + } + #[test] fn invalid_marker_parameters_do_not_edit_the_project() { let mut s = fixture(); let before = s.project.to_json(); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 9e17453a..7f434c70 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2289,6 +2289,21 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & } } + // ---- double-click any other clip (not on its edge): open its media in the Source monitor at + // the matching frame (Premiere); graphic clips have no source to show + if resp.double_clicked() + && tool == Tool::Selection + && let Some(p) = resp.interact_pointer_pos() + && let Hit::Clip { clip, edge: None, .. } = hit(seq, layout, p) + && seq + .find_item(clip) + .and_then(|(_, it)| app.session.project.item(it.item)) + .is_some_and(|i| !matches!(i.kind, filmcraft_project::ItemKind::Sequence(_) | filmcraft_project::ItemKind::Graphic { .. })) + && let Err(e) = app.session.execute("source.open", json!({"clip": clip.0})) + { + app.ui.status = e.to_string(); + } + // ---- context menu on clips (right-clicking an unselected clip selects it first); on a clip's // edge, the edit point menu instead (right-clicking an unselected edit point selects it first) let edit_point_menu_id = egui::Id::new("timeline.editPointMenu.target"); From fad5255dc91f7620f1f08ade598361f3d5b7ba50 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 16:08:58 -0400 Subject: [PATCH 119/144] media: importable tests after the items (clippy items_after_test_module, #677 follow-up) Co-Authored-By: Claude Opus 5.5 --- crates/media/src/lib.rs | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index d65a0630..bfc94de7 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -180,20 +180,6 @@ pub fn is_importable(path: &Path) -> bool { STILL_EXTENSIONS.contains(&ext.as_str()) || AUDIO_EXTENSIONS.contains(&ext.as_str()) || VIDEO_EXTENSIONS.contains(&ext.as_str()) } -#[cfg(test)] -mod importable_tests { - use super::*; - use std::path::Path; - - #[test] - fn camera_extensions_match_regardless_of_case() { - for name in ["clip.mov", "clip.MOV", "clip.Mov", "shot.MP4", "IMG_0001.JPG", "sound.WAV", "take.M2TS"] { - assert!(is_importable(Path::new(name)), "{name}"); - } - assert!(!is_importable(Path::new("notes.txt"))); - } -} - /// A pluggable opener (containers/codecs register themselves here from higher-level crates). pub type Opener = fn(name: &str, bytes: Arc<[u8]>) -> Option>; @@ -244,3 +230,17 @@ impl MediaSource for OfflineSource { Ok(AudioBuffer::silence(sample_rate, self.info.audio().map_or(2, |a| a.channels as usize), frames)) } } + +#[cfg(test)] +mod importable_tests { + use super::*; + use std::path::Path; + + #[test] + fn camera_extensions_match_regardless_of_case() { + for name in ["clip.mov", "clip.MOV", "clip.Mov", "shot.MP4", "IMG_0001.JPG", "sound.WAV", "take.M2TS"] { + assert!(is_importable(Path::new(name)), "{name}"); + } + assert!(!is_importable(Path::new("notes.txt"))); + } +} From 45f2b8e297079ae22d3dd6eb24f623c2fdfa29e6 Mon Sep 17 00:00:00 2001 From: Alex Brazh <69682842+alex-brazh@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:55:52 +0300 Subject: [PATCH 120/144] codecs: an open GOP's leading pictures decode from the GOP before, not as black frames In an open GOP (iPhone HEVC; x265 `open-gop`) each CRA after the first is followed in the file by RASL pictures shown before it, predicted from the GOP before. GopCache started a decoding run at `sync_before(i)`, the sync sample at or before the wanted sample in decoding order: for a RASL picture that is the CRA after it, and the decoders (software and VideoToolbox) rightly leave a run's leading RASL pictures out. The frame was never produced, so the request failed ("frame not produced") and the clip's layer was left out: a black frame (or the track below) wherever a seek landed on one of the 3-in-24 RASL frames of iPhone video, such as a clip trimmed to start on one, the parked playhead, and parallel export. - `decode_start`: a sample shown before its sync sample starts the run at the sync sample before (the MPEG and MXF readers already did this in their own `sync_before`; this covers MP4/MOV and Matroska too). - A running decoder that started after that point is not continued for it: it left the picture out. Tests: a synthetic open-GOP stream in gop.rs, and an x265 open-GOP oracle test (MP4 and Matroska, every frame from a fresh source and each CRA before its leading pictures) against ffmpeg. Both fail without the fix. --- crates/codecs/src/gop.rs | 111 ++++++++++++++++++++++++++++-- crates/codecs/tests/mp4_oracle.rs | 52 ++++++++++++++ 2 files changed, 159 insertions(+), 4 deletions(-) diff --git a/crates/codecs/src/gop.rs b/crates/codecs/src/gop.rs index b23b130e..c1395e43 100644 --- a/crates/codecs/src/gop.rs +++ b/crates/codecs/src/gop.rs @@ -122,6 +122,18 @@ pub trait VideoSamples { fn make_decoder(&self) -> crate::Result>; } +/// 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 +/// GOP before, so a decoder starting at their random-access picture leaves them out. +fn decode_start(s: &dyn VideoSamples, i: usize) -> usize { + let mut key = s.sync_before(i); + while key > 0 && s.pts(i) < s.pts(key) { + key = s.sync_before(key - 1).min(key - 1); + } + key +} + struct State { decoder: Option>, /// The decoder only produces intra pictures: frames decode independently and in parallel. @@ -604,12 +616,13 @@ impl GopCache { if st.intra { return self.intra_frame(st, s, i, want_pts); } - let mut key = s.sync_before(i); + let mut key = decode_start(s, i); // Continue the running decoder when it has passed the wanted sample's sync sample and // either has not reached the sample yet or has been fed it without outputting it yet // (a frame-threaded decoder holds many pictures in flight). Otherwise the frame was - // evicted or lies in another GOP: restart at the sync sample. - let running = st.next != usize::MAX && st.next <= n; + // evicted or lies in another GOP: restart at the sync sample. A run that started after + // `key` (at the random-access picture of a wanted leading picture) left the picture out. + let running = st.next != usize::MAX && st.next <= n && st.start <= key; // Decoding on through a short stretch into the next GOP is cheaper than a restart, and // playback wants those frames anyway. let near = st.next <= key && key - st.next <= CONTINUE_THROUGH; @@ -694,7 +707,7 @@ impl GopCache { want_pts: i64, n: usize, ) -> crate::Result> { - let mut key = s.sync_before(i); + let mut key = decode_start(s, i); // Continue the running decoder when the wanted sample is ahead within this GOP run. let continuing = *next != usize::MAX && *next > key && *next <= i + 16 && *next <= n; if !continuing { @@ -1261,4 +1274,94 @@ mod tests { } assert!(reused() - before >= more as u64 - 2, "{} of {more} planes recycled", reused() - before); } + + /// An open-GOP stream like iPhone HEVC: 8 pictures a GOP; after the first, each GOP stores + /// its random-access picture (display 8·g) first, then two leading pictures shown before it + /// (8·g − 2, 8·g − 1, predicted from the GOP before), then the rest. pts = 1000 · display. + struct OpenGop { + /// (display index, sync) in decode order + order: Vec<(i64, bool)>, + resets: Arc, + } + + impl OpenGop { + fn new(gops: i64) -> Self { + let mut order: Vec<(i64, bool)> = (0..6).map(|d| (d, d == 0)).collect(); + for g in 1..gops { + order.extend([(8 * g, true), (8 * g - 2, false), (8 * g - 1, false)]); + order.extend((8 * g + 1..8 * g + 6).map(|d| (d, false))); + } + Self { order, resets: Default::default() } + } + } + + /// Leaves out the leading pictures of the random-access picture a run starts at (RASL + /// pictures, as the HEVC decoders do), outputs the others at once. + struct OpenGopDec { + run_start: Option, + resets: Arc, + } + + impl VideoDecoder for OpenGopDec { + fn decode(&mut self, _sample: &[u8], pts: i64) -> crate::Result> { + let start = *self.run_start.get_or_insert(pts); + Ok(if pts < start { Vec::new() } else { vec![picture((pts, false))] }) + } + fn flush(&mut self) -> Vec { + Vec::new() + } + fn reset(&mut self) { + self.resets.fetch_add(1, Ordering::Relaxed); + self.run_start = None; + } + fn name(&self) -> &str { + "open gop" + } + } + + impl VideoSamples for OpenGop { + fn count(&self) -> usize { + self.order.len() + } + fn pts(&self, i: usize) -> i64 { + self.order[i].0 * 1000 + } + fn sync_before(&self, i: usize) -> usize { + (0..=i).rev().find(|&k| self.order[k].1).unwrap_or(0) + } + fn sample_at(&self, t: i64) -> Option { + self.order.iter().position(|&(d, _)| d == t / 1000) + } + fn read(&self, i: usize) -> crate::Result> { + Ok(vec![i as u8]) + } + fn make_decoder(&self) -> crate::Result> { + Ok(Box::new(OpenGopDec { run_start: None, resets: self.resets.clone() })) + } + } + + /// A leading picture of an open GOP, asked for first (a clip trimmed to start on it, the + /// playhead parked on it), came out black: decoding started at the random-access picture + /// after it, which leaves it out. + #[test] + fn leading_pictures_of_an_open_gop_decode_from_the_gop_before() { + let s = OpenGop::new(6); + for d in [6usize, 7, 14, 15, 39] { + let c = GopCache::new(None); + assert_eq!(index_of(&c.frame(&s, d as i64 * 1000).expect("frame")), d); + } + // the random-access picture first, then its leading pictures: the run that started at + // it left them out, so they restart a GOP earlier + let c = GopCache::new(None); + assert_eq!(index_of(&c.frame(&s, 16_000).expect("frame")), 16); + assert_eq!(index_of(&c.frame(&s, 15_000).expect("frame")), 15); + assert_eq!(index_of(&c.frame(&s, 14_000).expect("frame")), 14); + // playing through: one seek, every picture in turn + let s = OpenGop::new(6); + let c = GopCache::new(None); + for d in 0..46usize { + assert_eq!(index_of(&c.frame(&s, d as i64 * 1000).expect("frame")), d); + } + assert_eq!(s.resets.load(Ordering::Relaxed), 1); + } } diff --git a/crates/codecs/tests/mp4_oracle.rs b/crates/codecs/tests/mp4_oracle.rs index aef90e15..012424d7 100644 --- a/crates/codecs/tests/mp4_oracle.rs +++ b/crates/codecs/tests/mp4_oracle.rs @@ -163,3 +163,55 @@ fn cropped_pictures_match_ffmpeg() { } } } + +/// An open GOP (x265 `open-gop`, like iPhone HEVC): each CRA after the first is followed in the +/// file by RASL pictures shown before it, predicted from the GOP before. Asked for first (a clip +/// trimmed to start on one, the playhead parked on one), such a picture came out black: decoding +/// started at the CRA after it, which leaves its RASL pictures out. Every frame, asked of a fresh +/// source and in an order that visits each CRA before its leading pictures, is ffmpeg's, from MP4 +/// and from Matroska. +#[test] +fn open_gop_leading_pictures_decode_alone() { + let ff = filmcraft_testkit::require_ffmpeg!(); + let args = [ + "-f", + "lavfi", + "-i", + "testsrc2=size=160x96:rate=30:duration=2,noise=alls=10:allf=t", + "-c:v", + "libx265", + "-x265-params", + "log-level=error:keyint=12:min-keyint=12:scenecut=0:open-gop=1:bframes=3", + "-tag:v", + "hvc1", + "-pix_fmt", + "yuv420p", + ]; + let Some(mp4) = fixture(&ff, "open_gop_x265.mp4", &args) else { + eprintln!("SKIPPED: ffmpeg without libx265"); + return; + }; + let mkv = fixture(&ff, "open_gop_x265.mkv", &["-i", mp4.to_str().unwrap(), "-c", "copy"]).unwrap(); + let raw = ffmpeg_frames(&ff, &mp4, "yuv420p"); + let frame_bytes = 160 * 96 * 3 / 2; + let want: Vec<_> = raw.chunks(frame_bytes).map(|f| raw_planes(f, 160, 96, 80, 48, 1)).collect(); + assert_eq!(want.len(), 60); + for path in [&mp4, &mkv] { + let name = path.file_name().unwrap().to_str().unwrap(); + let open = || filmcraft_codecs::open_bytes(name, bytes(path)).unwrap(); + let rate = open().info().frame_rate(); + let frame = |src: &filmcraft_media::SharedSource, i: usize| { + let f = src.video_frame(filmcraft_media::FrameRequest::full(rate.tick_of(i as i64))).unwrap_or_else(|e| panic!("{name}: frame {i}: {e}")); + assert!(planes(&f) == want[i], "{name}: frame {i} differs from ffmpeg's"); + }; + for i in 0..want.len() { + frame(&open(), i); + } + let src = open(); + for cra in [12, 24, 36, 48] { + for i in [cra, cra - 1, cra - 2, cra - 3] { + frame(&src, i); + } + } + } +} From a8b3d7f70391e751934a76e80787ca0ff766b91f Mon Sep 17 00:00:00 2001 From: impostazioni <25422260+impostazioni@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:52:28 +0100 Subject: [PATCH 121/144] Timeline: Option-drag copies clips instead of moving them Holding Option (Alt) while dragging timeline clips now leaves the originals in place and drops copies where the pointer lets go, as in Premiere. The copies are selected; copies of linked clips are linked to each other, and transitions that would travel with a move are copied. - edit: copy_items (overwrite / insert, new ids, links and transitions) - engine: timeline.move takes `copy: true`, returns `copied` - ui: the drag remembers Option, shows the copy under the pointer while the originals stay, copy cursor - tests: engine copy/link/locked-track test, headless Option-drag UI test Co-Authored-By: Claude Opus 5.5 --- crates/edit/src/lib.rs | 54 +++++++++++ crates/engine/src/commands.rs | 27 +++++- crates/engine/src/timeline_move_tests.rs | 35 +++++++ crates/ui-egui/src/panels/timeline.rs | 27 +++++- .../ui-egui/tests/timeline_option_drag_ui.rs | 92 +++++++++++++++++++ docs/control-protocol.md | 5 +- 6 files changed, 230 insertions(+), 10 deletions(-) create mode 100644 crates/ui-egui/tests/timeline_option_drag_ui.rs diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index e12c5652..d79efe38 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -506,6 +506,60 @@ pub fn move_items(seq: &mut Sequence, moves: &[(ClipId, TrackId, Tick)], insert_ Ok(()) } +/// Copy items to (track, time) with overwrite or insert semantics, leaving the originals where +/// they are (Option-drag). `moves`: (item, destination track, start of the copy). The copies get +/// new ids; copies of linked clips are linked to each other, not to the originals, and the +/// transitions that would travel with a move are copied with them. Returns the copies' ids in +/// the order of `moves`. +pub fn copy_items(seq: &mut Sequence, moves: &[(ClipId, TrackId, Tick)], insert_mode: bool, ctx: &mut EditCtx) -> Result> { + let mut work = seq.clone(); + let mut placed = Vec::new(); + for (c, dest, start) in moves { + let (_, it) = work.find_item(*c).ok_or(EditError::NoItem(*c))?; + let mut it = it.clone(); + if work.track(*dest).ok_or(EditError::NoTrack(*dest))?.locked { + return Err(EditError::Locked); + } + it.start = (*start).max(Tick::ZERO); + placed.push((*dest, it)); + } + // worked out on the original ids, then moved over to the copies + let carried = carried_transitions(&work, &placed); + let mut ids = HashMap::new(); + let mut links = HashMap::new(); + for (_, it) in &mut placed { + let id = ClipId(ctx.alloc()); + ids.insert(it.id, id); + it.id = id; + if let Some(l) = it.link { + it.link = Some(*links.entry(l).or_insert_with(|| ctx.alloc())); + } + } + // a copy whose linked partner is not copied is not linked + let mut counts: HashMap = HashMap::new(); + for l in placed.iter().filter_map(|(_, it)| it.link) { + *counts.entry(l).or_default() += 1; + } + for (_, it) in &mut placed { + if it.link.is_some_and(|l| counts.get(&l).copied().unwrap_or(0) < 2) { + it.link = None; + } + } + let out = if insert_mode { insert(&mut work, placed, ctx)? } else { overwrite(&mut work, placed, ctx)? }; + for (dest, mut tr) in carried { + tr.id = TransitionId(ctx.alloc()); + tr.from = tr.from.and_then(|c| ids.get(&c).copied()); + tr.to = tr.to.and_then(|c| ids.get(&c).copied()); + let t = track_mut(&mut work, dest)?; + if [tr.from, tr.to].into_iter().flatten().all(|c| t.item(c).is_some()) && (tr.from.is_some() || tr.to.is_some()) { + t.transitions.push(tr); + t.sort(); + } + } + *seq = work; + Ok(out) +} + /// The transitions that travel with a move (#227, #374): those whose clips all move, onto one /// track and by one offset (a fade at a moved clip's edge, or the cut between two clips that move /// together), shifted to where those clips land. diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 6f2eb007..ab00abc2 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -302,7 +302,9 @@ pub(crate) fn item_p(p: &Value, k: &str) -> Option { /// every clip of the call lands later by the same amount, so the spacing asked for is kept. /// `overwritten` lists every clip the move covered without being asked to move it: its length /// before and after (ticks) and the clips what is left of it now lives on as (none = removed; a -/// clip a moved clip lands inside is split in two). +/// clip a moved clip lands inside is split in two). With `copy: true` (Option-drag) the clips stay +/// where they are and copies land at the destinations instead; the copies are selected and listed +/// as `copied`. fn move_clips(s: &mut Session, p: &Value) -> Result { const CMD: &str = "timeline.move"; let mut listed: Vec<(ClipId, TrackId, Tick)> = Vec::new(); @@ -345,19 +347,31 @@ fn move_clips(s: &mut Session, p: &Value) -> Result { moves.push((clip, track, start)); } let ins = bool_p(p, "insert").unwrap_or(false); + let copy = bool_p(p, "copy").unwrap_or(false); // every clip on the sequence now: id -> (track, start, end, project item) let spots = |s: &Session| -> Vec<(u64, TrackId, Tick, Tick, ItemId)> { let q = s.active_sequence(); q.into_iter().flat_map(|q| q.all_tracks()).flat_map(|t| t.items.iter().map(move |i| (i.id.0, t.id, i.start, i.end(), i.item))).collect() }; let before = spots(s); - s.edit_sequence(if ins { "Move (Insert)" } else { "Move" }, |q, ctx, _| Ok(edit::move_items(q, &moves, ins, ctx)?))?; + let copied = if copy { + let label = if ins { "Copy (Insert)" } else { "Copy" }; + s.edit_sequence(label, |q, ctx, st| { + let ids = edit::copy_items(q, &moves, ins, ctx)?; + st.selection = ids.clone(); + st.transition_selection.clear(); + Ok(ids) + })? + } else { + s.edit_sequence(if ins { "Move (Insert)" } else { "Move" }, |q, ctx, _| Ok(edit::move_items(q, &moves, ins, ctx)?))?; + Vec::new() + }; // An overwrite shortens, splits or removes whatever already sits where a clip lands, and a // shortened clip may live on under a new id: name each clip the move changed without being // asked to, with what is left of it, so the caller hears about it instead of finding out later. let after = spots(s); - let moved = |c: u64| moves.iter().any(|m| m.0.0 == c); - let new = |c: u64| !before.iter().any(|b| b.0 == c); + let moved = |c: u64| moves.iter().any(|m| m.0.0 == c) && !copy; + let new = |c: u64| !before.iter().any(|b| b.0 == c) && !copied.iter().any(|k| k.0 == c); let mut overwritten = Vec::new(); for &(c, track, start, end, item) in before.iter().filter(|b| !moved(b.0)) { let kept = after.iter().find(|a| a.0 == c); @@ -369,6 +383,9 @@ fn move_clips(s: &mut Session, p: &Value) -> Result { let now: i64 = pieces.iter().map(|a| (a.3 - a.2).0).sum(); overwritten.push(json!({"clip": c, "was": (end - start).0, "now": now, "pieces": pieces.iter().map(|a| a.0).collect::>()})); } + if copy { + return Ok(json!({"copied": copied.iter().map(|c| c.0).collect::>(), "overwritten": overwritten})); + } Ok(json!({"moved": moves.iter().map(|m| m.0.0).collect::>(), "overwritten": overwritten})) } @@ -2251,7 +2268,7 @@ fn build() -> Vec { "Move Clips", [], None, - r#"{"moves":[{"clip":id,"track":id|"V2","time":ticks}],"insert":bool,"linked":bool?}"#, + r#"{"moves":[{"clip":id,"track":id|"V2","time":ticks}],"insert":bool,"linked":bool?,"copy":bool?}"#, has_seq, move_clips ), diff --git a/crates/engine/src/timeline_move_tests.rs b/crates/engine/src/timeline_move_tests.rs index e80c2f97..6f176b81 100644 --- a/crates/engine/src/timeline_move_tests.rs +++ b/crates/engine/src/timeline_move_tests.rs @@ -247,3 +247,38 @@ fn a_move_that_lands_on_another_clip_reports_what_it_cut() { let r = s.execute("timeline.move", json!({"moves": [{"clip": a1.id.0, "track": a_track.0, "time": a2.start.0}], "insert": true, "linked": false})).unwrap(); assert_eq!(r["overwritten"], json!([])); } + +/// Option-drag (`copy: true`) moved the clips instead of copying them. +#[test] +fn copy_leaves_the_originals_and_links_the_copies() { + let mut s = demo(); + let (v_track, v, a_track, a) = pair_in_an_empty_sequence(&mut s, 0.0); + let to = v.end() + s.sequence_rate().tick_of(24); + let before = s.project.clone(); + // the picture is listed; its linked sound is copied with it + let r = s.execute("timeline.move", json!({"moves": [{"clip": v.id.0, "track": "V2", "time": to.0}], "copy": true})).unwrap(); + let copies: Vec = r["copied"].as_array().unwrap().iter().map(|c| ClipId(c.as_u64().unwrap())).collect(); + assert_eq!(copies.len(), 2, "picture and sound are copied: {r}"); + assert_eq!((state(&s, v.id), state(&s, a.id)), ((v_track, v.start), (a_track, a.start)), "the originals stay"); + let q = s.active_sequence().unwrap(); + let (vt, vc) = q.find_item(copies[0]).unwrap(); + let (at, ac) = q.find_item(copies[1]).unwrap(); + assert_eq!((vt, vc.start, vc.item, vc.source_in, vc.duration), (q.video_tracks[1].id, to, v.item, v.source_in, v.duration)); + assert_eq!((at, ac.start), (a_track, to), "the sound copy keeps its offset on its own track"); + assert!(vc.link.is_some() && vc.link == ac.link && vc.link != v.link, "the copies are linked to each other only"); + assert_eq!(s.state.selection, copies, "the copies are selected"); + q.check().unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(*s.project, *before, "one undo step"); + // copying just one side (linked: false) leaves the copy unlinked + let r = s.execute("timeline.move", json!({"moves": [{"clip": a.id.0, "track": a_track.0, "time": to.0}], "copy": true, "linked": false})).unwrap(); + let c = ClipId(r["copied"][0].as_u64().unwrap()); + assert_eq!(r["copied"].as_array().unwrap().len(), 1); + assert!(s.active_sequence().unwrap().find_item(c).unwrap().1.link.is_none()); + // a copy onto a locked track is refused and changes nothing + s.execute("edit.undo", json!({})).unwrap(); + s.execute("timeline.setTrack", json!({"track": "V2", "locked": true})).unwrap(); + let r = s.execute("timeline.move", json!({"moves": [{"clip": v.id.0, "track": "V2", "time": to.0}], "copy": true, "linked": false})); + assert!(r.is_err()); + assert_eq!(s.active_sequence().unwrap().all_tracks().map(|t| t.items.len()).sum::(), 2); +} diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 28b75008..55c8b9c1 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -89,6 +89,9 @@ pub enum Drag { start_track: TrackId, offset: Tick, track_delta: i32, + /// Option is held: the drop copies the clips instead of moving them (as of the last + /// drag frame, so letting go of both at once still copies). + copy: bool, }, Trim { clip: ClipId, @@ -398,6 +401,12 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { // item, so the waveform and thumbnail stay where the media is instead of squeezing (#374) let mut edited: HashMap = HashMap::new(); preview_drag(app, &seq, &mut previews, &mut edited); + // Option-drag copies: the clips stay where they are and their copies follow the pointer + let copying = matches!(app.tl.drag, Some(Drag::Move { copy: true, .. })); + let copies: Vec<(ClipId, (Tick, Tick, Option))> = if copying { previews.drain().collect() } else { Vec::new() }; + if copying { + ctx.set_cursor_icon(CursorIcon::Copy); + } for r in &rows { let Some(tr) = seq.track(r.track) else { continue }; let clip_rect = if r.kind == TrackKind::Video { vclip } else { aclip }; @@ -449,6 +458,15 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } } } + for (cid, (start, dur, mt)) in &copies { + if let Some((home, it)) = seq.find_item(*cid) + && mt.unwrap_or(home) == r.track + { + let body = Rect::from_min_max(pos2(layout.x_of(*start), r.rect.min.y + 1.0), pos2(layout.x_of(*start + *dur), r.rect.max.y - 1.0)); + draw_clip(app, &ctx, &p, body, it, r.kind, true, &t, rate); + app.auto.add(&format!("timeline.clip.{}.copy", cid.0), body.intersect(content), &it.name); + } + } // Show Through Edits: a small bow-tie on cuts between continuous pieces of one clip if app.session.state.show_through_edits { for te in filmcraft_edit::through::track_through_edits(tr) { @@ -2080,7 +2098,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & } if resp.drag_started() { let clips = app.session.state.selection.clone(); - Some(Drag::Move { clips, grab_tick: t, start_track: track, offset: Tick::ZERO, track_delta: 0 }) + Some(Drag::Move { clips, grab_tick: t, start_track: track, offset: Tick::ZERO, track_delta: 0, copy: mods.alt }) } else { None } @@ -2173,7 +2191,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & (Some(c), Some(s)) if c.kind == s.kind => c.index as i32 - s.index as i32, _ => 0, }; - Some(Drag::Move { clips, grab_tick, start_track, offset, track_delta }) + Some(Drag::Move { clips, grab_tick, start_track, offset, track_delta, copy: mods.alt }) } Drag::Trim { clip, edge, mode, from, .. } => seq.find_item(clip).map(|(_, it)| { let base = if edge == filmcraft_edit::Edge::In { it.start } else { it.end() }; @@ -2240,12 +2258,13 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & && let Some(d) = app.tl.drag.take() { let r = match d { - Drag::Move { clips, offset, track_delta, .. } if offset != Tick::ZERO || track_delta != 0 => { + Drag::Move { clips, offset, track_delta, copy, .. } if offset != Tick::ZERO || track_delta != 0 => { let moves: Vec = clips .iter() .filter_map(|c| seq.find_item(*c).map(|(tid, it)| json!({"clip": c.0, "track": shift_track(seq, tid, track_delta).unwrap_or(tid).0, "time": (it.start + offset).max(Tick::ZERO).0}))) .collect(); - Some(app.session.execute("timeline.move", json!({"moves": moves, "insert": mods.command, "linked": false}))) + // Option held while dropping copies (Premiere: Option-drag duplicates) + Some(app.session.execute("timeline.move", json!({"moves": moves, "insert": mods.command, "linked": false, "copy": copy || mods.alt}))) } Drag::Trim { clip, edge, mode, delta, .. } if delta != Tick::ZERO => Some(app.session.execute( "timeline.trim", diff --git a/crates/ui-egui/tests/timeline_option_drag_ui.rs b/crates/ui-egui/tests/timeline_option_drag_ui.rs new file mode 100644 index 00000000..01840f43 --- /dev/null +++ b/crates/ui-egui/tests/timeline_option_drag_ui.rs @@ -0,0 +1,92 @@ +//! Option-drag of a timeline clip copies it (Premiere): the original stays, a copy lands where +//! the pointer lets go and is selected; a plain drag still moves. + +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); + let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.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 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"] == id).unwrap_or_else(|| panic!("no element {id}")).clone(); + 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 clip_count(d: &mut Driver) -> usize { + d.harness.state().session.active_sequence().map(|q| q.all_tracks().map(|t| t.items.len()).sum()).unwrap_or(0) +} + +#[test] +fn option_drag_copies_a_clip_and_a_plain_drag_moves_it() { + let mut d = Driver::demo(); + // the clip on V2 (no linked sound), and empty space to its right + let q = d.harness.state().session.active_sequence().unwrap().clone(); + let v2 = q.video_tracks[1].items[0].clone(); + let n = clip_count(&mut d); + let r = d.rect(&format!("timeline.clip.{}", v2.id.0)); + let (x, y) = (r[0] + 10.0, r[1] + r[3] / 2.0); + d.ok("ui.drag", json!({"from": {"x": x, "y": y}, "to": {"x": x + 240.0, "y": y}, "steps": 12, "modifiers": {"alt": true}})); + d.frames(3); + let q2 = d.harness.state().session.active_sequence().unwrap().clone(); + assert_eq!(clip_count(&mut d), n + 1, "one copy was added"); + assert_eq!(q2.find_item(v2.id).map(|(_, i)| i.start), Some(v2.start), "the original stays"); + let sel = d.harness.state().session.state.selection.clone(); + assert_eq!(sel.len(), 1); + let (track, copy) = q2.find_item(sel[0]).unwrap(); + assert_ne!(copy.id, v2.id); + assert_eq!((track, copy.item, copy.duration), (q.video_tracks[1].id, v2.item, v2.duration)); + assert!(copy.start > v2.start, "the copy is where the pointer let go"); + // one undo removes the copy + d.exec("edit.undo", json!({})); + assert_eq!(clip_count(&mut d), n); + // without Option the same drag moves the clip + d.ok("ui.drag", json!({"from": {"x": x, "y": y}, "to": {"x": x + 240.0, "y": y}, "steps": 12})); + d.frames(3); + assert_eq!(clip_count(&mut d), n); + assert!(d.harness.state().session.active_sequence().unwrap().find_item(v2.id).unwrap().1.start > v2.start, "moved"); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 28b86c30..fc8f62a8 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -102,7 +102,10 @@ is, and so does one whose position would not change (the listed clip only change lists every clip that moved, partners included. No clip is moved before the sequence start: if one would be, all clips of the call land later by the same amount (their spacing is kept). A `time` beyond the representable range is an "invalid parameters" error. The timeline -panel's drag passes `linked: false`, because it already lists exactly the clips it moves. +panel's drag passes `linked: false`, because it already lists exactly the clips it moves. With +`copy: true` (Option-drag in the timeline) the clips stay where they are and copies land at the +destinations instead (linked partners are copied too, unless `linked: false`); the result lists +the new clips as `copied`, they are selected, and copies of linked clips are linked to each other. **Trim mode** (`crates/engine/src/trim.rs`): `trim.selectEditPoint` / `trim.selectNearest` enter trim mode (the Program monitor becomes the Trim Monitor, ids `trimMonitor.*`); `trim.monitor` returns what From 62358d8c4ee4b98caace78aad0369dbd84b4e043 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:21:23 +0200 Subject: [PATCH 122/144] text: the CJK system-font search no longer keeps every probed font file in memory (#769) --- crates/text/src/fonts.rs | 34 ++++++++++++++++++++++++++++++++++ crates/ui-egui/src/cjk.rs | 2 +- crates/ui-egui/src/i18n/mod.rs | 2 +- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 6cceff10..a6c3f444 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -215,6 +215,18 @@ impl Face { pub fn has_char(&self, c: char) -> bool { self.glyph(c).is_some() } + /// Whether the face covers every character of `text`. Unlike [`Face::has_char`], a face whose + /// file is not loaded yet is read into a temporary buffer that is dropped afterwards, so + /// probing many system faces does not keep every font file in memory. + pub fn covers_text(&self, text: &str) -> bool { + if let (FaceData::File(p), None) = (&self.info.data, self.bytes.get()) { + let Ok(data) = std::fs::read(p) else { return false }; + let Ok(font) = FontRef::from_index(&data, self.info.index) else { return false }; + let charmap = font.charmap(); + return text.chars().all(|c| charmap.map(c).is_some_and(|g| g.to_u32() != 0)); + } + text.chars().all(|c| self.has_char(c)) + } /// Height of the glyph's vertical origin above its baseline, in pixels. /// OpenType VORG takes precedence; TrueType uses the ink top plus vmtx's top bearing. pub(crate) fn vertical_origin(&self, gid: u32, px: f32) -> Option { @@ -569,4 +581,26 @@ mod tests { assert!(!f.has_char('\u{5d0}'), "Inter has no Hebrew"); assert!(f.has_feature(b"liga") || f.has_feature(b"calt")); } + + #[test] + fn covers_text_does_not_keep_file_bytes() { + let path = std::env::temp_dir().join(format!("filmcraft-covers-text-{}.ttf", std::process::id())); + std::fs::write(&path, INTER_REGULAR).expect("write temp font"); + let info = FaceInfo { + family: "Inter".into(), + style: "Regular".into(), + weight: 400, + italic: false, + index: 0, + data: FaceData::File(path.clone()), + origin: "system", + }; + let f = Face { id: 0, info, bytes: OnceLock::new(), shaper: OnceLock::new() }; + assert!(f.covers_text("A\u{416}")); + assert!(!f.covers_text("A\u{5d0}")); + assert!(f.bytes.get().is_none(), "probing coverage must not cache the file"); + assert!(f.data().is_some()); + assert!(f.bytes.get().is_some()); + let _ = std::fs::remove_file(&path); + } } diff --git a/crates/ui-egui/src/cjk.rs b/crates/ui-egui/src/cjk.rs index 789ae78b..1ebdf010 100644 --- a/crates/ui-egui/src/cjk.rs +++ b/crates/ui-egui/src/cjk.rs @@ -33,7 +33,7 @@ fn system_font() -> Option> { } filmcraft_text::fonts::scan_system(); let faces: Vec<_> = filmcraft_text::fonts::all_faces().into_iter().filter(|f| f.info.origin == "system" && !f.info.italic).collect(); - let covers = |f: &filmcraft_text::fonts::Face| SAMPLE.chars().all(|c| f.has_char(c)); + let covers = |f: &filmcraft_text::fonts::Face| f.covers_text(SAMPLE); let by_weight = |f: &&Arc| f.info.weight.abs_diff(400); let preferred = PREFERRED.iter().find_map(|name| faces.iter().filter(|f| f.info.family.eq_ignore_ascii_case(name) && covers(f)).min_by_key(by_weight)); let face = preferred.or_else(|| faces.iter().filter(|f| covers(f)).min_by_key(by_weight))?; diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index db2be192..1cbf5b05 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -247,7 +247,7 @@ pub fn system_japanese_font() -> Option> { FONT.get_or_init(|| { filmcraft_text::fonts::scan_system(); let faces: Vec<_> = filmcraft_text::fonts::all_faces().into_iter().filter(|f| f.info.origin == "system" && !f.info.italic).collect(); - let covers = |f: &filmcraft_text::fonts::Face| JAPANESE_SAMPLE.chars().all(|c| f.has_char(c)); + let covers = |f: &filmcraft_text::fonts::Face| f.covers_text(JAPANESE_SAMPLE); // within a family, the face closest to regular weight let by_weight = |f: &&Arc| f.info.weight.abs_diff(400); let preferred = From 8417e46090d6b39a2ad28dfd8f5ca42f7a7f1abc Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:22:52 +0200 Subject: [PATCH 123/144] Timeline: draw each audio track's own waveform, not track 1's (#770) --- crates/ui-egui/src/panels/menu_dialogs.rs | 3 +- crates/ui-egui/src/panels/monitor_view.rs | 2 +- crates/ui-egui/src/panels/timeline.rs | 128 ++++++++++++++++------ crates/ui-egui/tests/nest_rendering_ui.rs | 2 +- 4 files changed, 96 insertions(+), 39 deletions(-) diff --git a/crates/ui-egui/src/panels/menu_dialogs.rs b/crates/ui-egui/src/panels/menu_dialogs.rs index e3431398..1d35c21e 100644 --- a/crates/ui-egui/src/panels/menu_dialogs.rs +++ b/crates/ui-egui/src/panels/menu_dialogs.rs @@ -199,7 +199,8 @@ pub fn route(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: &Val if let Ok(v) = &r { let mut peaks = app.tl.peaks.lock().unwrap_or_else(|e| e.into_inner()); for i in v["items"].as_array().cloned().unwrap_or_default() { - peaks.remove(&filmcraft_engine::project::ItemId(i.as_u64().unwrap_or_default())); + let item = filmcraft_engine::project::ItemId(i.as_u64().unwrap_or_default()); + peaks.retain(|(it, _), _| *it != item); } } return Some(r); diff --git a/crates/ui-egui/src/panels/monitor_view.rs b/crates/ui-egui/src/panels/monitor_view.rs index dd3db365..15e6ab6f 100644 --- a/crates/ui-egui/src/panels/monitor_view.rs +++ b/crates/ui-egui/src/panels/monitor_view.rs @@ -575,7 +575,7 @@ pub fn waveform( } let mid = area.center().y; p.line_segment([pos2(area.min.x, mid), pos2(area.max.x, mid)], Stroke::new(1.0, Color32::from_white_alpha(30))); - match crate::panels::timeline::request_peaks(app, item) { + match crate::panels::timeline::request_peaks(app, item, 0) { Some(peaks) if !peaks.is_empty() => { let half = area.height() * 0.45; let spp = 256.0 * TICKS_PER_SAMPLE_48K; diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 3bac089b..99dcccc7 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -34,6 +34,9 @@ const MASTER_H: f32 = 34.0; const GAP_FILL: Color32 = Color32::from_gray(224); const SNAP_PX: f32 = 9.0; +/// Waveform peaks are kept per audio stream: (item, index into the media's `audio_streams`). +pub type PeakKey = (ItemId, usize); + /// Transient interaction state. #[derive(Default)] pub struct TlState { @@ -44,8 +47,8 @@ pub struct TlState { pub layout: Option, /// Snap indicator x (screen) this frame. snap_x: Option, - pub peaks: Arc>>>>, - peaks_pending: Arc>>, + pub peaks: Arc>>>>, + peaks_pending: Arc>>, /// Nested sequences have waveforms too, of their mix. Unlike media a sequence changes: this is /// the content key (see [`sequence_audio_key`]) the cached peaks of each sequence were made /// from, and the key worked out for the session revision last seen. @@ -53,7 +56,7 @@ pub struct TlState { seq_keys_seen: HashMap, /// Source peak of each cached peak list (keyed by the list's address), for the waveform /// display gain: scanning the whole source per clip per frame cost more than drawing. - peak_max: HashMap, + peak_max: HashMap, zoom_anchor: Option<(f64, f32)>, } @@ -791,7 +794,8 @@ fn draw_clip( } fn draw_waveform(app: &mut FilmcraftApp, p: &egui::Painter, body: Rect, it: &TrackItem, col: Color32) { - let Some(peaks) = request_peaks(app, it.item) else { return }; + let Some(peaks) = request_peaks(app, it.item, it.audio_stream) else { return }; + let pkey = (it.item, it.audio_stream); let spp = 256.0; let sr = 48_000.0; let zone_h = (body.height() * 0.42).max(8.0); @@ -801,11 +805,11 @@ fn draw_waveform(app: &mut FilmcraftApp, p: &egui::Painter, body: Rect, it: &Tra return; } let key = Arc::as_ptr(&peaks) as usize; - let peak = match app.tl.peak_max.get(&it.item) { + let peak = match app.tl.peak_max.get(&pkey) { Some(&(k, v)) if k == key => v, _ => { let v = peaks.iter().fold(0f32, |m, (a, b)| m.max(a.abs()).max(b.abs())); - app.tl.peak_max.insert(it.item, (key, v)); + app.tl.peak_max.insert(pkey, (key, v)); v } }; @@ -881,17 +885,18 @@ fn request_sequence_peaks(app: &mut FilmcraftApp, item: ItemId) -> Option Option Option Option>> { +/// Min/max peaks (one per 256 samples at 48 kHz) of audio stream `stream` of `src`, `dur` long. +fn source_peaks(src: &dyn filmcraft_media::MediaSource, stream: usize, dur: Tick) -> Vec<(f32, f32)> { + let sr = 48_000u32; + let total = dur.to_units_floor(sr as i64).max(0) as usize; + let mut out = Vec::with_capacity(total / 256 + 1); + let chunk = 48_000 * 4; + let mut s = 0usize; + while s < total { + let n = chunk.min(total - s); + match src.audio_stream(stream, s as i64, n, sr) { + Ok(buf) => { + let ch = buf.channels.first().cloned().unwrap_or_default(); + for c in ch.chunks(256) { + let (lo, hi) = c.iter().fold((0f32, 0f32), |(l, h), v| (l.min(*v), h.max(*v))); + out.push((lo, hi)); + } + } + Err(_) => break, + } + s += n; + } + out +} + +/// Waveform peaks of audio stream `stream` of `item` (a clip's `audio_stream`). +pub(crate) fn request_peaks(app: &mut FilmcraftApp, item: ItemId, stream: usize) -> Option>> { if app.session.project.sequence(item).is_some() { return request_sequence_peaks(app, item); } - if let Some(p) = app.tl.peaks.lock().unwrap_or_else(|e| e.into_inner()).get(&item) { + let pkey = (item, stream); + if let Some(p) = app.tl.peaks.lock().unwrap_or_else(|e| e.into_inner()).get(&pkey) { return Some(p.clone()); } // No source: try again on a later frame instead of leaving the item pending forever. let src = app.session.source(item)?; { let mut pend = app.tl.peaks_pending.lock().unwrap_or_else(|e| e.into_inner()); - if pend.contains(&item) { + if pend.contains(&pkey) { return None; } - pend.push(item); + pend.push(pkey); } let peaks = app.tl.peaks.clone(); let pending = app.tl.peaks_pending.clone(); let dur = src.info().duration; let run = move || { - let sr = 48_000u32; - let total = dur.to_units_floor(sr as i64).max(0) as usize; - let mut out = Vec::with_capacity(total / 256 + 1); - let chunk = 48_000 * 4; - let mut s = 0usize; - while s < total { - let n = chunk.min(total - s); - match src.audio(s as i64, n, sr) { - Ok(buf) => { - let ch = buf.channels.first().cloned().unwrap_or_default(); - for c in ch.chunks(256) { - let (lo, hi) = c.iter().fold((0f32, 0f32), |(l, h), v| (l.min(*v), h.max(*v))); - out.push((lo, hi)); - } - } - Err(_) => break, - } - s += n; - } - peaks.lock().unwrap_or_else(|e| e.into_inner()).insert(item, Arc::new(out)); - pending.lock().unwrap_or_else(|e| e.into_inner()).retain(|i| *i != item); + let out = source_peaks(&*src, stream, dur); + peaks.lock().unwrap_or_else(|e| e.into_inner()).insert(pkey, Arc::new(out)); + pending.lock().unwrap_or_else(|e| e.into_inner()).retain(|i| *i != pkey); }; #[cfg(not(target_arch = "wasm32"))] std::thread::spawn(run); @@ -2697,6 +2710,49 @@ mod trim_preview_tests { } } + /// A source whose audio streams are different constants (0.1 for stream 0, 0.5 for stream 1). + struct TwoStreams(filmcraft_media::MediaInfo); + + impl filmcraft_media::MediaSource for TwoStreams { + fn info(&self) -> &filmcraft_media::MediaInfo { + &self.0 + } + fn video_frame(&self, _req: filmcraft_media::FrameRequest) -> filmcraft_media::Result> { + Err(filmcraft_media::MediaError::NoStream("video")) + } + fn audio(&self, _start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result { + Ok(filmcraft_frame::AudioBuffer { sample_rate, channels: vec![vec![0.1; frames]] }) + } + fn audio_stream(&self, stream: usize, start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result { + match stream { + 0 => self.audio(start, frames, sample_rate), + 1 => Ok(filmcraft_frame::AudioBuffer { sample_rate, channels: vec![vec![0.5; frames]] }), + _ => Err(filmcraft_media::MediaError::NoStream("audio")), + } + } + } + + #[test] + fn each_audio_stream_gets_its_own_waveform_peaks() { + let info = filmcraft_media::MediaInfo { + name: "two.mkv".into(), + kind: filmcraft_media::MediaKind::Movie, + duration: Tick(TICKS_PER_SECOND), + video: None, + audio_streams: Vec::new(), + container: "mkv".into(), + start_timecode: None, + file_size: None, + }; + let src = TwoStreams(info); + let first = source_peaks(&src, 0, Tick(TICKS_PER_SECOND)); + let second = source_peaks(&src, 1, Tick(TICKS_PER_SECOND)); + assert!(!first.is_empty() && first.len() == second.len()); + assert!(first.iter().all(|&(_, hi)| (hi - 0.1).abs() < 1e-6), "stream 0 is the quiet one"); + assert!(second.iter().all(|&(_, hi)| (hi - 0.5).abs() < 1e-6), "stream 1 is its own audio, not stream 0 again"); + assert!(source_peaks(&src, 2, Tick(TICKS_PER_SECOND)).is_empty(), "a missing stream has no peaks"); + } + #[test] fn trimming_the_music_keeps_its_waveform_in_place() { let (mut app, seq) = demo(); diff --git a/crates/ui-egui/tests/nest_rendering_ui.rs b/crates/ui-egui/tests/nest_rendering_ui.rs index 6ddfe6ce..e75837f7 100644 --- a/crates/ui-egui/tests/nest_rendering_ui.rs +++ b/crates/ui-egui/tests/nest_rendering_ui.rs @@ -71,7 +71,7 @@ impl Driver { fn peaks_when(&mut self, item: u64, ready: impl Fn(&[(f32, f32)]) -> bool) -> Vec<(f32, f32)> { for _ in 0..400 { self.frames(1); - let got = self.app().tl.peaks.lock().unwrap().get(&ItemId(item)).cloned(); + let got = self.app().tl.peaks.lock().unwrap().get(&(ItemId(item), 0)).cloned(); if let Some(p) = got.filter(|p| ready(p)) { return p.to_vec(); } From 6aca7c1798a9b7bd2c724f532c2ae46a6b698c3a Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 23:39:57 +0200 Subject: [PATCH 124/144] #781: cargo xtask parity measures menu coverage Problem: the menu parity figure in docs/target-app-parity.md came from an ad hoc label-matching script over string literals; nothing repeatable measured the real menu bar (docs/gaps.md G7). Fix: `cargo xtask parity` dumps the shipped menu bar (new `menus::default_menu_items`, deterministic: English, default prefs, built-in workspaces; printed by `examples/menu_tree.rs`) and compares it with a reference list of menu paths, by default the local, never-committed `plan/premiere/menus.json` (any JSON of paths via --reference; parsing is depth- and size-capped). Presence and placement are reported separately (same path, elsewhere, missing); app/Help/recent/account/macOS items are out of scope. Writes a markdown checklist (target/parity/ or --out) and --min fails below a presence threshold. Without a reference it prints our inventory only. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/examples/menu_tree.rs | 26 ++ crates/ui-egui/src/menus.rs | 32 ++ crates/ui-egui/src/panels/workspaces.rs | 18 +- docs/gaps.md | 17 +- docs/target-app-parity.md | 4 +- xtask/src/main.rs | 10 +- xtask/src/parity.rs | 507 ++++++++++++++++++++++++ 7 files changed, 602 insertions(+), 12 deletions(-) create mode 100644 crates/ui-egui/examples/menu_tree.rs create mode 100644 xtask/src/parity.rs diff --git a/crates/ui-egui/examples/menu_tree.rs b/crates/ui-egui/examples/menu_tree.rs new file mode 100644 index 00000000..62610ad1 --- /dev/null +++ b/crates/ui-egui/examples/menu_tree.rs @@ -0,0 +1,26 @@ +//! Print FilmCraft's menu bar as it ships (English, default preferences) as JSON: one object per +//! item with its `id`, `label` and menu `path`. `cargo xtask parity` reads this to measure menu +//! coverage against a reference list. +//! +//! ```sh +//! cargo run -p filmcraft-ui-egui --example menu_tree +//! ``` + +use std::process::ExitCode; + +use serde_json::json; + +fn main() -> ExitCode { + let items: Vec = + filmcraft_ui_egui::menus::default_menu_items().into_iter().map(|it| json!({"id": it.id, "label": it.label, "path": it.path})).collect(); + match serde_json::to_string_pretty(&items) { + Ok(text) => { + println!("{text}"); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("menu_tree: {e}"); + ExitCode::FAILURE + } + } +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 358a6530..c0095c6e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -636,6 +636,16 @@ pub fn menu_items(app: &FilmcraftApp) -> Vec { v } +/// The menu bar as it ships, without a running app: English labels, default preferences, the +/// built-in workspaces, nothing checked. Deterministic, so `cargo xtask parity` can measure it +/// (`examples/menu_tree.rs` prints it as JSON). +pub fn default_menu_items() -> Vec { + let session = filmcraft_engine::Session::default(); + let mut v = menu_items_for(&session); + crate::panels::workspaces::default_menu(&mut v, &session); + v +} + /// Menu entries with their live shortcuts and enablement. pub fn menu_items_for(session: &filmcraft_engine::Session) -> Vec { let mut out = Vec::new(); @@ -869,3 +879,25 @@ mod parse_shortcut_tests { assert_eq!(parse_shortcut("+").map(|(_, k)| k), Some(egui::Key::Plus)); } } + +#[cfg(test)] +mod tests { + use super::*; + + /// What `cargo xtask parity` measures: every item sits under a top-level menu, and Window ▸ + /// Workspaces lists the built-in workspaces before the layout commands, none checked. + #[test] + fn default_menu_items_are_the_shipped_menu_bar() { + let items = default_menu_items(); + assert!(items.len() > 300, "{} items", items.len()); + assert!(items.iter().all(|it| it.path.first().is_some_and(|top| MENUS.contains(&top.as_str())))); + let ws: Vec<&MenuItem> = items.iter().filter(|it| it.path == ["Window", "Workspaces"]).collect(); + for name in crate::dock::WORKSPACES { + assert!(ws.iter().any(|it| it.label == name && it.checked.is_none()), "no workspace {name}"); + } + let editing = ws.iter().position(|it| it.label == "Editing"); + let reset = ws.iter().position(|it| it.id == "window.workspace.reset"); + assert!(matches!((editing, reset), (Some(e), Some(r)) if e < r), "workspaces come before the layout commands"); + assert!(items.iter().any(|it| it.id == "file.save" && it.path == ["File"])); + } +} diff --git a/crates/ui-egui/src/panels/workspaces.rs b/crates/ui-egui/src/panels/workspaces.rs index 69d52944..502e3091 100644 --- a/crates/ui-egui/src/panels/workspaces.rs +++ b/crates/ui-egui/src/panels/workspaces.rs @@ -129,21 +129,31 @@ fn delete(app: &mut FilmcraftApp, name: &str) -> Result { /// Rebuild the Window ▸ Workspaces submenu: every workspace (the current one checked), then the /// layout commands in table order. pub fn menu(app: &FilmcraftApp, items: &mut Vec) { + splice(items, &app.workspaces, &app.session, Some(&app.ui.workspace), |n| app.ui.language.tr(n).to_string()); +} + +/// The same submenu without a running app: the built-in workspaces in English, none checked +/// (`menus::default_menu_items`). +pub fn default_menu(items: &mut Vec, session: &filmcraft_engine::Session) { + splice(items, &dock::WorkspacePrefs::default(), session, None, str::to_string); +} + +fn splice(items: &mut Vec, prefs: &dock::WorkspacePrefs, session: &filmcraft_engine::Session, current: Option<&str>, tr: impl Fn(&str) -> String) { let is_ws = |it: &MenuItem| it.path.len() == 2 && it.path[0] == "Window" && it.path[1] == "Workspaces"; let Some(at) = items.iter().position(is_ws) else { return }; let actions: Vec = items.iter().filter(|it| is_ws(it) && it.id.strip_prefix("window.workspace.").is_some_and(|k| ACTIONS.contains(&k))).cloned().collect(); items.retain(|it| !is_ws(it)); - let mut sub: Vec = dock::names(&app.workspaces) + let mut sub: Vec = dock::names(prefs) .into_iter() .map(|n| { let id = format!("window.workspace.{}", slug(&n)); MenuItem { - label: app.ui.language.tr(&n).to_string(), + label: tr(&n), path: vec!["Window".into(), "Workspaces".into()], - shortcut: app.session.shortcuts.primary(&id), + shortcut: session.shortcuts.primary(&id), enabled: true, - checked: Some(n == app.ui.workspace), + checked: current.map(|c| n == c), id, } }) diff --git a/docs/gaps.md b/docs/gaps.md index 3532ae7b..c57e02b6 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -122,12 +122,17 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** ## G7. No measured parity tool in CI -- **Missing:** `cargo xtask parity` (menus, commands, effects, transitions, panels, preferences, - formats against `plan/premiere/` snapshots), presence and fidelity reported separately (exact, - approximate, stub), generating `docs/parity-checklist.md`. -- **Evidence:** the 92% menu figure in this pass came from an ad hoc label-matching script. -- **Estimate:** 10–20 h (the menu snapshot is local-only, so the tool needs a committed, Adobe-free - list of menu paths or runs only where `plan/` exists). +- **Done:** `cargo xtask parity` measures menus (#781): our shipped menu bar (the tree the app + builds, `examples/menu_tree.rs`) against a reference list of menu paths, by default the local + `plan/premiere/menus.json`; presence and placement reported separately (same path, elsewhere, + missing), app / Help / recent / account / macOS items out of scope, a markdown checklist, and + `--min` to fail below a presence threshold. Without a reference it prints our inventory only. +- **Missing:** the other areas (commands, effects, transitions, panels, preferences, formats), + fidelity tiers (exact, approximate, stub), a CI job, and a committed `docs/parity-checklist.md`. +- **Evidence:** the 92% menu figure in this pass came from an ad hoc label-matching script; it has + not yet been re-measured with the tool. +- **Estimate:** 8–17 h left (the menu snapshot is local-only, so CI needs a committed, Adobe-free + list of menu paths for `--reference`, or the check runs only where `plan/` exists). ## G8. UI fidelity: monitor handles, Effect Controls, docking diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 29b2f706..2e3f7fb8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -45,7 +45,9 @@ issues now document. That is new evidence, not drift: see [Calibration](#calibra Audio Clip / Track Effect Editor, Sequence Index, AI Models and Collaboration settings, UXP / Exchange plug-in entries. A label match is presence only, and can over-count (a label that exists in a different place); it is a lower bound on misses, not proof of behaviour. The script - is ad hoc; turning it into `cargo xtask parity` is gap [G7](gaps.md#g7-no-measured-parity-tool-in-ci). + was ad hoc; `cargo xtask parity` now measures the shipped menu bar against the snapshot, + reporting presence and placement separately (not yet re-run for this figure); the other areas + are gap [G7](gaps.md#g7-no-measured-parity-tool-in-ci). - **Effects (measured by tests).** `premiere_26_video_effects_catalogue`, `premiere_audio_set_is_complete_and_foldered` and `premiere_26_transition_tree` check the catalogue against Premiere's Effects panel: video effects 93/93 (+Legacy, Obsolete), audio diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 648f217b..1ea276af 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -11,10 +11,14 @@ //! - `fixtures [crate…]`: pre-generate the ffmpeg fixture matrix of the oracle tests (runs each //! crate's ignored `generate_fixtures` test, i.e. the same generators the tests use) and print //! what was made, reused or skipped. +//! - `parity [--reference FILE] [--out FILE] [--min PERCENT]`: measured menu parity of our shipped +//! menu bar against a reference list of menu paths (default: the local `plan/premiere/menus.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. mod ico; +mod parity; mod version; use std::process::{Command, ExitCode}; @@ -487,12 +491,16 @@ fn main() -> ExitCode { let rest: Vec = std::env::args().skip(2).collect(); version::run(&root(), &rest.iter().map(String::as_str).collect::>()) } + "parity" => { + let rest: Vec = std::env::args().skip(2).collect(); + parity::run(&root(), &rest.iter().map(String::as_str).collect::>()) + } "ico" => { let rest: Vec = std::env::args().skip(2).collect(); ico::run(&rest.iter().map(String::as_str).collect::>()) } _ => Err( - "usage: cargo xtask " + "usage: cargo xtask " .into(), ), }; diff --git a/xtask/src/parity.rs b/xtask/src/parity.rs new file mode 100644 index 00000000..e11a570f --- /dev/null +++ b/xtask/src/parity.rs @@ -0,0 +1,507 @@ +//! `cargo xtask parity [--reference FILE] [--ours FILE] [--out FILE] [--min PERCENT]`: measured +//! menu parity (docs/gaps.md G7). +//! +//! Compares FilmCraft's shipped menu bar (`crates/ui-egui/examples/menu_tree.rs`, or a saved dump +//! of it given with `--ours`) with a reference list of menu paths, and reports presence and +//! placement separately: a reference item is *same place* when we have its label at the same menu +//! path, *elsewhere* when we have the label under another path, and *missing* otherwise. A label +//! match is presence only, not behaviour. +//! +//! The reference defaults to the maintainers' local menu snapshot `plan/premiere/menus.json` +//! (gitignored, never committed). Without one the tool prints our own menu inventory and measures +//! nothing. The reference may be any JSON of menu paths: an array of paths (`["File", "New", +//! "Sequence…"]`) or of `"File > New > Sequence…"` strings, a tree of nodes (`{"name": …, +//! "children" | "items" | "submenu": […]}`), or nested objects keyed by label. App, Apple and Help +//! menus, recent-file lists, account / cloud items and macOS system items are counted out of scope. +//! +//! The report goes to `target/parity/parity-checklist.md` (or `--out`); `--min` fails when presence +//! is below the given percentage. + +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use serde_json::Value; + +/// Deepest menu nesting accepted from a reference file. +const MAX_DEPTH: usize = 16; +/// Most menu items accepted from a reference file. +const MAX_ITEMS: usize = 20_000; +/// 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"]; +/// Keys of a wrapper object around the whole menu bar. +const WRAPPER_KEYS: [&str; 5] = ["menus", "menuBar", "menu_bar", "menubar", "items"]; + +struct Args { + reference: Option, + ours: Option, + out: Option, + min: Option, +} + +fn parse_args(args: &[&str]) -> Result { + let mut a = Args { reference: None, ours: None, out: None, min: None }; + let mut it = args.iter(); + while let Some(flag) = it.next() { + let mut value = || it.next().map(|v| v.to_string()).ok_or_else(|| format!("{flag} needs a value")); + match *flag { + "--reference" => a.reference = Some(value()?.into()), + "--ours" => a.ours = Some(value()?.into()), + "--out" => a.out = Some(value()?.into()), + "--min" => { + let v = value()?; + let pct: f64 = v.parse().map_err(|_| format!("--min {v}: not a number"))?; + if !(0.0..=100.0).contains(&pct) { + return Err(format!("--min {v}: must be 0–100")); + } + a.min = Some(pct); + } + other => { + return Err(format!( + "parity: unknown argument {other} (usage: cargo xtask parity [--reference FILE] [--ours FILE] [--out FILE] [--min PERCENT])" + )); + } + } + } + Ok(a) +} + +pub fn run(root: &Path, args: &[&str]) -> Result<(), String> { + let args = parse_args(args)?; + let ours_json = match &args.ours { + Some(p) => std::fs::read_to_string(p).map_err(|e| format!("{}: {e}", p.display()))?, + None => dump_ours()?, + }; + let ours = our_items(&serde_json::from_str(&ours_json).map_err(|e| format!("our menu dump: {e}"))?)?; + let default_ref = root.join("plan/premiere/menus.json"); + let reference = match &args.reference { + Some(p) => Some(p.clone()), + None => default_ref.exists().then_some(default_ref), + }; + let Some(reference) = reference else { + print!("{}", inventory(&ours)); + println!("\nno reference menu list (plan/premiere/menus.json, or --reference FILE): menu parity not measured"); + return match args.min { + Some(_) => Err("--min needs a reference menu list".into()), + None => Ok(()), + }; + }; + let text = std::fs::read_to_string(&reference).map_err(|e| format!("{}: {e}", reference.display()))?; + let value: Value = serde_json::from_str(&text).map_err(|e| format!("{}: {e}", reference.display()))?; + let paths = reference_paths(&value)?; + let name = reference.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let report = compare(&paths, &ours); + let out = args.out.unwrap_or_else(|| crate::target_dir().join("parity/parity-checklist.md")); + if let Some(dir) = out.parent() { + std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + } + std::fs::write(&out, report.markdown(&name, ours.len())).map_err(|e| format!("{}: {e}", out.display()))?; + println!("{}", report.summary()); + println!("report: {}", out.display()); + match args.min { + Some(min) if report.presence() < min => Err(format!("menu presence {:.1}% is below --min {min}%", report.presence())), + _ => Ok(()), + } +} + +/// Our menu bar as JSON, from the `menu_tree` example. +fn dump_ours() -> Result { + let mut cmd = Command::new(env!("CARGO")); + cmd.args(["run", "-q", "-p", "filmcraft-ui-egui", "--example", "menu_tree"]).stderr(Stdio::inherit()); + let out = cmd.output().map_err(|e| format!("menu_tree: {e}"))?; + if !out.status.success() { + return Err(format!("menu_tree failed: {}", out.status)); + } + String::from_utf8(out.stdout).map_err(|e| format!("menu_tree: {e}")) +} + +/// Full paths (menu path + label) of our menu items, from the `menu_tree` dump. +fn our_items(v: &Value) -> Result>, String> { + let items = v.as_array().ok_or("our menu dump is not an array")?; + Ok(items + .iter() + .filter_map(|it| { + let label = it.get("label")?.as_str()?; + let mut path: Vec = it.get("path")?.as_array()?.iter().filter_map(|p| p.as_str().map(str::to_string)).collect(); + path.push(label.to_string()); + (path.len() >= 2).then_some(path) + }) + .collect()) +} + +/// Item counts per top-level menu, for runs without a reference. +fn inventory(ours: &[Vec]) -> String { + let mut per: BTreeMap<&str, usize> = BTreeMap::new(); + for p in ours { + if let Some(top) = p.first() { + *per.entry(top.as_str()).or_default() += 1; + } + } + let mut s = format!("FilmCraft menu bar: {} items\n", ours.len()); + for (top, n) in per { + s.push_str(&format!(" {top:<22} {n}\n")); + } + s +} + +/// Leaf menu paths (top-level menu first, item label last) of a reference file. +fn reference_paths(v: &Value) -> Result>, String> { + let mut root = v; + if let Value::Object(o) = v + && label_of(o).is_none() + && let Some(inner) = WRAPPER_KEYS.iter().find_map(|k| o.get(*k).filter(|c| c.is_array() || c.is_object())) + { + root = inner; + } + let mut out = Vec::new(); + walk(root, &[], 0, &mut out)?; + Ok(out) +} + +fn label_of(o: &serde_json::Map) -> Option<&str> { + LABEL_KEYS.iter().find_map(|k| o.get(*k)?.as_str()) +} + +/// A separator or placeholder rather than a menu item. +fn is_separator(label: &str) -> bool { + let l = label.trim(); + l.is_empty() || l.chars().all(|c| matches!(c, '-' | '—' | '–' | '_')) || l.eq_ignore_ascii_case("missing value") || l.eq_ignore_ascii_case("separator") +} + +fn push_leaf(prefix: &[String], label: &str, out: &mut Vec>) -> Result<(), String> { + if is_separator(label) || prefix.iter().any(|p| is_separator(p)) { + return Ok(()); + } + if out.len() >= MAX_ITEMS { + return Err(format!("reference has more than {MAX_ITEMS} menu items")); + } + let mut path = prefix.to_vec(); + path.push(label.trim().to_string()); + if path.len() >= 2 { + out.push(path); + } + Ok(()) +} + +fn split_path(s: &str) -> Option> { + let parts: Vec = s.replace('\u{a0}', " ").split(['>', '▸']).map(|p| p.trim().to_string()).collect(); + (parts.len() >= 2 && parts.iter().all(|p| !p.is_empty())).then_some(parts) +} + +fn walk(v: &Value, prefix: &[String], depth: usize, out: &mut Vec>) -> Result<(), String> { + if depth > MAX_DEPTH { + return Err(format!("reference menus are nested deeper than {MAX_DEPTH} levels")); + } + let with = |label: &str| { + let mut p = prefix.to_vec(); + p.push(label.trim().to_string()); + p + }; + match v { + Value::String(s) => match split_path(s).filter(|_| prefix.is_empty()) { + Some(parts) => match parts.split_last() { + Some((label, menus)) => push_leaf(menus, label, out), + None => Ok(()), + }, + None => push_leaf(prefix, s, out), + }, + Value::Array(a) => { + for el in a { + match el { + Value::Array(path) if !path.is_empty() && path.iter().all(Value::is_string) => { + let parts: Vec = prefix.iter().cloned().chain(path.iter().filter_map(|p| p.as_str().map(str::to_string))).collect(); + if let Some((label, menus)) = parts.split_last() { + push_leaf(menus, label, out)?; + } + } + _ => walk(el, prefix, depth + 1, out)?, + } + } + Ok(()) + } + Value::Object(o) => match label_of(o) { + Some(label) => { + let children = CHILD_KEYS.iter().find_map(|k| o.get(*k).filter(|c| has_entries(c))); + match children { + Some(c) if !is_separator(label) => walk(c, &with(label), depth + 1, out), + Some(_) => Ok(()), + None => push_leaf(prefix, label, out), + } + } + None => { + for (k, val) in o { + if has_entries(val) { + walk(val, &with(k), depth + 1, out)?; + } else { + push_leaf(prefix, k, out)?; + } + } + Ok(()) + } + }, + _ => Ok(()), + } +} + +fn has_entries(v: &Value) -> bool { + match v { + Value::Array(a) => !a.is_empty(), + Value::Object(o) => !o.is_empty(), + _ => false, + } +} + +/// A label compared case-insensitively, without ellipsis, with `&` as "and" and one space. +fn norm(label: &str) -> String { + let l = label.to_lowercase().replace('…', "").replace("...", "").replace(['’', '‘'], "'").replace('&', " and "); + l.split_whitespace().collect::>().join(" ") +} + +/// Why a reference item is not counted, if it isn't. +fn out_of_scope(path: &[String]) -> Option<&'static str> { + let (top, label) = (norm(path.first()?), norm(path.last()?)); + if top == "apple" || top == "help" || top.contains("premiere") { + return Some("app, Apple and Help menus"); + } + let parents = path.get(..path.len().saturating_sub(1)).unwrap_or_default(); + if parents.iter().skip(1).any(|p| norm(p).starts_with("open recent") || norm(p).starts_with("recent")) { + return Some("recent-file lists"); + } + if ["adobe", "creative cloud", "sign in", "sign out", "log in", "log out"].iter().any(|w| label.contains(w)) { + return Some("account and cloud items"); + } + if ["autofill", "start dictation", "emoji and symbols", "writing tools", "services"].iter().any(|w| label.starts_with(w)) { + return Some("macOS system items"); + } + None +} + +enum Placement { + Same, + Elsewhere(Vec), + Missing, +} + +struct Report { + /// (reference path, where it is in FilmCraft). + items: Vec<(Vec, Placement)>, + out_of_scope: BTreeMap<&'static str, usize>, + ours_only: Vec>, +} + +fn compare(reference: &[Vec], ours: &[Vec]) -> Report { + let key = |p: &[String]| p.iter().map(|s| norm(s)).collect::>(); + let our_paths: HashSet> = ours.iter().map(|p| key(p)).collect(); + let mut by_label: HashMap> = HashMap::new(); + for p in ours { + if let Some(l) = p.last() { + by_label.entry(norm(l)).or_insert(p); + } + } + let mut report = Report { items: Vec::new(), out_of_scope: BTreeMap::new(), ours_only: Vec::new() }; + let mut seen = HashSet::new(); + for p in reference { + if !seen.insert(key(p)) { + continue; + } + if let Some(why) = out_of_scope(p) { + *report.out_of_scope.entry(why).or_default() += 1; + continue; + } + let label = p.last().map(|l| norm(l)).unwrap_or_default(); + let placement = if our_paths.contains(&key(p)) { + Placement::Same + } else if let Some(at) = by_label.get(&label) { + Placement::Elsewhere((*at).clone()) + } else { + Placement::Missing + }; + report.items.push((p.clone(), placement)); + } + let ref_labels: HashSet = reference.iter().filter_map(|p| p.last().map(|l| norm(l))).collect(); + report.ours_only = ours.iter().filter(|p| p.last().is_some_and(|l| !ref_labels.contains(&norm(l)))).cloned().collect(); + report +} + +fn pct(n: usize, of: usize) -> f64 { + if of == 0 { 0.0 } else { n as f64 * 100.0 / of as f64 } +} + +fn show(p: &[String]) -> String { + p.join(" ▸ ") +} + +impl Report { + fn counts(&self) -> (usize, usize, usize) { + let same = self.items.iter().filter(|(_, pl)| matches!(pl, Placement::Same)).count(); + let elsewhere = self.items.iter().filter(|(_, pl)| matches!(pl, Placement::Elsewhere(_))).count(); + (same, elsewhere, self.items.len().saturating_sub(same + elsewhere)) + } + + /// Reference items whose label we have anywhere, in percent of the items in scope. + fn presence(&self) -> f64 { + let (same, elsewhere, _) = self.counts(); + pct(same + elsewhere, self.items.len()) + } + + fn summary(&self) -> String { + let (same, elsewhere, missing) = self.counts(); + let n = self.items.len(); + format!( + "menus: presence {}/{n} ({:.1}%), same place {same}/{n} ({:.1}%), elsewhere {elsewhere}, missing {missing}, out of scope {}", + same + elsewhere, + self.presence(), + pct(same, n), + self.out_of_scope.values().sum::() + ) + } + + fn markdown(&self, reference: &str, ours: usize) -> String { + let (same, elsewhere, missing) = self.counts(); + let n = self.items.len(); + let mut s = String::from("# Menu parity checklist\n\n"); + s.push_str(&format!( + "Generated by `cargo xtask parity` from `{reference}` ({n} items in scope, {} out of scope) against FilmCraft's \ + shipped menu bar ({ours} items). *Same place*: we have the label at the same menu path; *elsewhere*: under \ + another path; *missing*: nowhere. A label match is presence only, not behaviour.\n\n", + self.out_of_scope.values().sum::() + )); + s.push_str("| Menu | Items | Same place | Elsewhere | Missing | Presence |\n|---|---|---|---|---|---|\n"); + let mut per: Vec<(&str, [usize; 3])> = Vec::new(); + for (p, pl) in &self.items { + let top = p.first().map(String::as_str).unwrap_or_default(); + let i = match per.iter().position(|(t, _)| *t == top) { + Some(i) => i, + None => { + per.push((top, [0; 3])); + per.len() - 1 + } + }; + let col = match pl { + Placement::Same => 0, + Placement::Elsewhere(_) => 1, + Placement::Missing => 2, + }; + if let Some(c) = per.get_mut(i).and_then(|(_, c)| c.get_mut(col)) { + *c += 1; + } + } + for (top, [a, b, c]) in &per { + let total = a + b + c; + s.push_str(&format!("| {top} | {total} | {a} | {b} | {c} | {:.1}% |\n", pct(a + b, total))); + } + s.push_str(&format!("| **Total** | **{n}** | **{same}** | **{elsewhere}** | **{missing}** | **{:.1}%** |\n", self.presence())); + s.push_str(&format!("\n## Missing ({missing})\n\n")); + for (p, _) in self.items.iter().filter(|(_, pl)| matches!(pl, Placement::Missing)) { + s.push_str(&format!("- [ ] {}\n", show(p))); + } + s.push_str(&format!("\n## Elsewhere in FilmCraft ({elsewhere})\n\n")); + for (p, pl) in &self.items { + if let Placement::Elsewhere(at) = pl { + s.push_str(&format!("- {} → {}\n", show(p), show(at))); + } + } + s.push_str("\n## Out of scope\n\n"); + for (why, k) in &self.out_of_scope { + s.push_str(&format!("- {why}: {k}\n")); + } + s.push_str(&format!("\n## FilmCraft only ({})\n\n", self.ours_only.len())); + for p in &self.ours_only { + s.push_str(&format!("- {}\n", show(p))); + } + s + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn paths(v: &[&[&str]]) -> Vec> { + v.iter().map(|p| p.iter().map(|s| s.to_string()).collect()).collect() + } + + #[test] + fn reference_formats_give_the_same_paths() { + let want = paths(&[&["File", "New", "Sequence…"], &["File", "Save"], &["Edit", "Undo"]]); + let forms = [ + json!([["File", "New", "Sequence…"], ["File", "Save"], ["Edit", "Undo"]]), + json!(["File > New > Sequence…", "File ▸ Save", "Edit > Undo"]), + json!({"menus": [ + {"name": "File", "children": [ + {"name": "New", "items": [{"name": "Sequence…", "shortcut": "Cmd+N"}]}, + {"name": "missing value"}, + {"title": "Save"} + ]}, + {"name": "Edit", "submenu": [{"label": "Undo"}]} + ]}), + json!({"Edit": {"Undo": "Cmd+Z"}, "File": {"New": ["Sequence…"], "-": null, "Save": null}}), + ]; + for f in forms { + let mut got = reference_paths(&f).unwrap(); + got.sort(); + let mut w = want.clone(); + w.sort(); + assert_eq!(got, w, "{f}"); + } + } + + #[test] + fn hostile_reference_is_refused_not_overflowed() { + let mut v = json!("leaf"); + for _ in 0..200 { + v = json!({ "name": "m", "children": [v] }); + } + assert!(reference_paths(&v).unwrap_err().contains("nested deeper")); + let many: Vec = (0..MAX_ITEMS + 1).map(|i| format!("File > Item {i}")).collect(); + assert!(reference_paths(&json!(many)).unwrap_err().contains("more than")); + // odd values are skipped, not trusted + assert_eq!(reference_paths(&json!([1, null, true, {"name": 5}, ["File"]])).unwrap(), Vec::>::new()); + } + + #[test] + fn presence_and_placement_are_reported_separately() { + let reference = paths(&[ + &["File", "New", "Sequence..."], + &["File", "Export Frame"], + &["Edit", "Find & Replace"], + &["Edit", "Spelling"], + &["Help", "Premiere Pro Help…"], + &["File", "Open Recent", "clip.prproj"], + &["Edit", "AutoFill"], + &["Edit", "Spelling"], + ]); + let ours = paths(&[&["File", "New", "Sequence…"], &["File", "Export", "Export Frame"], &["Edit", "Find and Replace…"], &["Clip", "Nest…"]]); + let r = compare(&reference, &ours); + assert_eq!(r.counts(), (2, 1, 1), "duplicates count once; out-of-scope items are not counted"); + assert!((r.presence() - 75.0).abs() < 1e-9); + assert_eq!(r.out_of_scope.values().sum::(), 3); + assert_eq!(r.ours_only, paths(&[&["Clip", "Nest…"]])); + let md = r.markdown("menus.json", ours.len()); + assert!(md.contains("| **Total** | **4** | **2** | **1** | **1** | **75.0%** |"), "{md}"); + assert!(md.contains("- [ ] Edit ▸ Spelling")); + assert!(md.contains("- File ▸ Export Frame → File ▸ Export ▸ Export Frame")); + assert!(r.summary().contains("presence 3/4 (75.0%), same place 2/4 (50.0%)")); + } + + #[test] + fn empty_reference_measures_zero_without_dividing_by_zero() { + let r = compare(&[], &paths(&[&["File", "Save"]])); + assert_eq!(r.presence(), 0.0); + assert!(r.summary().contains("presence 0/0 (0.0%)")); + } + + #[test] + fn our_dump_and_arguments_are_checked() { + let ours = our_items(&json!([{"id": "file.save", "label": "Save", "path": ["File"]}, {"id": "x", "label": "Orphan", "path": []}, {"bad": 1}])).unwrap(); + assert_eq!(ours, paths(&[&["File", "Save"]])); + assert!(our_items(&json!({})).is_err()); + assert!(parse_args(&["--min", "101"]).is_err()); + assert!(parse_args(&["--min", "x"]).is_err()); + assert!(parse_args(&["--out"]).is_err()); + assert!(parse_args(&["--bogus"]).is_err()); + assert!(parse_args(&["--min", "90", "--reference", "r.json"]).is_ok_and(|a| a.min == Some(90.0) && a.reference.is_some())); + } +} From c635d9af9e11690172466c4604bbf6de183e0b98 Mon Sep 17 00:00:00 2001 From: SilentYeti Date: Fri, 9 Oct 2026 23:48:21 -0700 Subject: [PATCH 125/144] Linux: H.265 export through NVENC The NVENC HEVC encoder (Main 8-bit SDR and Main 10 HDR PQ / HLG) was gated to Windows. It now also runs on 64-bit Linux, on the CUDA device the H.264 encoder already uses: the H.265 format and HDR probes are registered there and the HEVC tests run on both systems. NVENC registration on Linux now happens after the VA-API probe. Started beside it, the background HEVC probe's first cuInit could deadlock with libva-nvidia-driver, which calls cuInit from its library constructor under the loader lock. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +-- crates/export/src/settings.rs | 2 +- crates/platform/README.md | 24 +++++------ crates/platform/src/lib.rs | 42 +++++++++++-------- crates/platform/src/nvenc/export.rs | 5 +-- crates/platform/src/nvenc/mod.rs | 4 +- crates/platform/tests/nvenc_hevc.rs | 2 +- crates/platform/tests/nvenc_hevc_export.rs | 2 +- .../platform/tests/nvenc_hevc_hdr_export.rs | 2 +- crates/platform/tests/nvenc_hevc_main10.rs | 2 +- crates/platform/tests/nvenc_hevc_probe.rs | 2 +- crates/platform/tests/nvenc_hevc_warm.rs | 2 +- crates/platform/tests/nvenc_rgba_input.rs | 4 +- docs/codec-parity.md | 2 +- docs/target-app-parity.md | 4 +- docs/testing.md | 2 +- 16 files changed, 56 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index e74820ab..81968754 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,7 @@ No FFmpeg inside. The video codecs, AAC, Opus and the containers are our own Rus | **Matroska / WebM** | ✓ | | Lacing, Cues, header stripping, HDR colour metadata | | **Stills** | ✓ | ✓ | Import PNG, JPEG, GIF, WebP, TIFF and BMP; 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)). +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 and Linux, VideoToolbox on macOS) ([#30](https://github.com/storytold/filmcraft/issues/30)).
@@ -289,10 +289,10 @@ FilmCraft is in **alpha**. We track two numbers ([docs/target-app-parity.md](doc The biggest gaps today: -- **Speed on big footage.** Hardware decoding works on macOS and Windows, and for H.264 and HEVC on Linux (VA-API, or NVDEC with NVIDIA's driver; VP9 and AV1 decode in software there). Blend modes and the most common effects run on the GPU, but Lumetri, keys and export rendering still run on the CPU; H.264 encoding can use the hardware encoder on macOS and on Windows and Linux with an NVIDIA GPU (opt-in, Export ▸ Hardware encoding), and H.265 export is hardware-only on both (HDR sequences export Main 10 PQ / HLG with NVENC; VideoToolbox writes 8-bit SDR) ([#30](https://github.com/storytold/filmcraft/issues/30)). +- **Speed on big footage.** Hardware decoding works on macOS and Windows, and for H.264 and HEVC on Linux (VA-API, or NVDEC with NVIDIA's driver; VP9 and AV1 decode in software there). Blend modes and the most common effects run on the GPU, but Lumetri, keys and export rendering still run on the CPU; H.264 encoding can use the hardware encoder on macOS and on Windows and Linux with an NVIDIA GPU (opt-in, Export ▸ Hardware encoding), and H.265 export is hardware-only on all three (HDR sequences export Main 10 PQ / HLG with NVENC; VideoToolbox writes 8-bit SDR) ([#30](https://github.com/storytold/filmcraft/issues/30)). - **Premiere projects.** `.prproj` files can't be opened yet; bring sequences over as FCP7 XML, AAF or OTIO. - **No plugins.** No VST3 / Audio Units or OpenFX hosting. -- **Delivery codecs.** H.264 is our only software delivery-codec export; H.265 exports only through a hardware encoder (NVIDIA on Windows: Main 8-bit SDR and Main 10 HDR PQ / HLG; VideoToolbox on macOS: 8-bit Main, SDR); no AV1 export yet. +- **Delivery codecs.** H.264 is our only software delivery-codec export; H.265 exports only through a hardware encoder (NVIDIA on Windows and Linux: Main 8-bit SDR and Main 10 HDR PQ / HLG; VideoToolbox on macOS: 8-bit Main, SDR); no AV1 export yet. - **Real-world media and platforms.** Our decoders are bit-exact on conformance streams, but camera and phone files in the wild are less tested. Windows and Linux get far less testing than macOS. - **AI features.** Few so far; speech to text is optional and off by default. diff --git a/crates/export/src/settings.rs b/crates/export/src/settings.rs index fc220e8b..b7b0c730 100644 --- a/crates/export/src/settings.rs +++ b/crates/export/src/settings.rs @@ -62,7 +62,7 @@ impl H264Profile { } /// Export ▸ Hardware encoding: whether H.264 may be encoded by the system's hardware video encoder -/// (VideoToolbox on macOS, NVENC on NVIDIA GPUs on Windows) instead of FilmCraft's own encoder. Off +/// (VideoToolbox on macOS, NVENC on NVIDIA GPUs on Windows and Linux) instead of FilmCraft's own encoder. Off /// by default: hardware encoders make different streams, and exports are otherwise byte-identical /// from run to run and machine to machine. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/platform/README.md b/crates/platform/README.md index b78c5773..cefdfa0d 100644 --- a/crates/platform/README.md +++ b/crates/platform/README.md @@ -61,7 +61,7 @@ let availability = filmcraft_platform::register(); // Available("VideoToolbox") - After a `flush` (which drains the MFT) the MFT only restarts at an IDR picture; the GOP cache always seeks after a flush, and a caller that continues from the middle of a GOP gets an error that `HybridDecoder` answers by replaying the run in software. -- **Windows and 64-bit Linux: NVIDIA NVENC H.264 encoding, and H.265 (HEVC) on Windows** (`nvenc/`), 8-bit SDR 4:2:0 for Export. The driver's +- **Windows and 64-bit Linux: NVIDIA NVENC H.264 and H.265 (HEVC) encoding** (`nvenc/`), 8-bit SDR 4:2:0 for Export. The driver's `nvEncodeAPI64.dll` (Windows) or `libnvidia-encode.so.1` (Linux), API 12.1, is loaded at run time, so machines without NVIDIA still start. Windows uses a Direct3D 11 device; Linux retains a primary CUDA context through the runtime-loaded `libcuda.so.1`, released after the encoder is destroyed. @@ -76,8 +76,8 @@ let availability = filmcraft_platform::register(); // Available("VideoToolbox") It declines two-pass VBR, HDR, MXF, interlaced output, sizes outside NVENC's limits and systems without an NVIDIA GPU or driver, and the software encoder runs instead. A failure during an export ends it with an error, since a hardware stream cannot be finished in software. The same session, - ring and input path encode H.265, see [Hardware H.265 (HEVC) encoding (Windows, - NVENC)](#hardware-h265-hevc-encoding-windows-nvenc). + ring and input path encode H.265, see [Hardware H.265 (HEVC) encoding + (NVENC)](#hardware-h265-hevc-encoding-nvenc). - **Linux: NVDEC, H.264 (`avcC`) and HEVC (`hvcC`)** on NVIDIA's proprietary driver (`nvdec/`): H.264 8-bit and HEVC 8- / 10-bit, 4:2:0 progressive. `libnvcuvid.so.1` and `libcuda.so.1` are loaded at run time; without them (or without a device) nothing is registered. NVIDIA's own parser takes the @@ -188,7 +188,7 @@ The sample entry's `hvcC` record is the one VideoToolbox wrote for the stream (r format description's sample description extension atoms, with the VPS / SPS / PPS), so profile, level and flags are the encoder's own. If it is missing the export stops with that reason. -## Hardware H.265 (HEVC) encoding (Windows, NVENC) +## Hardware H.265 (HEVC) encoding (NVENC, Windows and Linux) One codec enum (`nvenc::Codec`, chosen by `Profile::HevcMain` the way `VtProfile::HevcMain` does it for VideoToolbox) parameterises the NVENC session: the codec and profile GUIDs, the capability @@ -295,13 +295,13 @@ level 4 High tier, which many hardware decoders refuse; it is now level 4.1 Main | `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/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/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 | +| `tests/nvenc_hevc.rs` (Windows / Linux, 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 / Linux, 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 / Linux, 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 / Linux) | 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 / Linux) | `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 / Linux, 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 / Linux, 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 | | `crates/export/src/hdr_tests.rs` | `rgbf_to_yuv420_10`: exact codes for black, white, grey and the BT.2020 primaries and secondaries, NaN / infinity / out-of-range, odd sizes, wrong lengths and overflowing sizes; the HDR probe registry; which exports are HDR (HEVC SDR without a probe, HDR with one, `settings.sdr` forcing SDR); the SEI of the software H.264 encoder equals `ColorSignal::static_metadata` | @@ -344,7 +344,7 @@ Zero-copy upload of decoded pictures into wgpu textures (`CVPixelBuffer`s on mac textures on Windows); B-frames in the VideoToolbox encoders and 10-bit / HDR HEVC (Main 10) in VideoToolbox; VP9 and AV1 through VA-API (Linux), and VA-API encoding; field-coded H.264; 4:4:4 and 4:2:2 encoding with NVENC, HDR H.264 with NVENC (HDR H.264 stays with the software encoder), HDR10 MaxCLL / MaxFALL measured from -the pictures (they are written as 0, unknown), AV1 with NVENC, H.265 with the other Windows vendors and on Linux, and encoders +the pictures (they are written as 0, unknown), AV1 with NVENC, H.265 with the other Windows and Linux vendors, and encoders from other vendors on Windows (through Media Foundation); VP9 / AV1 4:4:4 and 12-bit on Windows. ### Linux NVENC sources and limits diff --git a/crates/platform/src/lib.rs b/crates/platform/src/lib.rs index 8744da0e..e42db755 100644 --- a/crates/platform/src/lib.rs +++ b/crates/platform/src/lib.rs @@ -8,7 +8,7 @@ //! Linux with neither) registration does nothing and reports [`Availability::Unavailable`]. It also registers a //! hardware H.264 encoder factory (`filmcraft_export::register_encoder`) that only acts when an //! export asks for it (`ExportSettings::hardware_encoding` = `Auto`), see [`hardware_encode`]; on -//! macOS and on Windows (NVENC, [`nvenc`]) it also makes the H.265 export format available, and on Windows +//! macOS and with NVENC ([`nvenc`], Windows and Linux) it also makes the H.265 export format available, and with NVENC //! (Main 10) lets HDR sequences export as HDR H.265. //! //! Hardware decoding never makes a file undecodable: @@ -68,8 +68,6 @@ pub enum Availability { /// harmless). Streams they do not take, and every stream while hardware decoding is Off, keep /// using FilmCraft's own decoders. pub fn register() -> Availability { - #[cfg(all(target_os = "linux", target_pointer_width = "64"))] - filmcraft_export::register_encoder(nvenc::export::factory); #[cfg(target_os = "macos")] { filmcraft_codecs::register_video_decoder(videotoolbox_factory); @@ -81,23 +79,9 @@ pub fn register() -> Availability { filmcraft_codecs::hw::set_hw_backend("VideoToolbox"); Availability::Available("VideoToolbox") } - // Export ▸ H.265: NVENC is FilmCraft's only HEVC encoder on Windows, so the format is available - // when a small HEVC session opens. That takes about half a second, so it is asked on a thread of - // its own now rather than by the first draw of the format list. #[cfg(target_os = "windows")] { - filmcraft_export::register_format_probe(filmcraft_export::Format::Hevc, nvenc::hevc_available); - // HDR sequences export as HEVC Main 10 (PQ / HLG) only where NVENC has a 10-bit encoder; elsewhere - // (and on macOS, whose VideoToolbox path is 8-bit) they are tone-mapped to SDR, as before - filmcraft_export::register_hdr_probe(filmcraft_export::Format::Hevc, nvenc::hevc_hdr_available); - nvenc::warm_hevc_probe(); - } - #[cfg(target_os = "windows")] - { - static ENCODERS: std::sync::Once = std::sync::Once::new(); - // Export ▸ Hardware encoding (NVENC H.264): in front of the software encoder, taking an - // export only when asked for and when NVENC can do it - ENCODERS.call_once(|| filmcraft_export::register_encoder(nvenc::export::factory)); + register_nvenc(); filmcraft_codecs::register_video_decoder(media_foundation_factory); filmcraft_codecs::hw::set_hw_backend("Media Foundation"); Availability::Available("Media Foundation") @@ -130,6 +114,11 @@ pub fn register() -> Availability { } Err(why) => log::info!("no NVDEC hardware decoding: {why}"), } + // After the VA-API probe, never beside it: libva-nvidia-driver calls `cuInit` from its + // library constructor (under the loader lock) while a first `cuInit` on another thread + // loads libraries under CUDA's own lock, and the two wait for each other forever. + #[cfg(target_pointer_width = "64")] + register_nvenc(); available } #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] @@ -138,6 +127,23 @@ pub fn register() -> Availability { } } +/// NVENC: the Export encoder factory, and what makes the H.265 format (and HDR H.265) available. +#[cfg(any(target_os = "windows", all(target_os = "linux", target_pointer_width = "64")))] +fn register_nvenc() { + // Export ▸ H.265: NVENC is FilmCraft's only HEVC encoder on Windows and Linux, so the format is available + // when a small HEVC session opens. That takes about half a second, so it is asked on a thread of + // its own now rather than by the first draw of the format list. + filmcraft_export::register_format_probe(filmcraft_export::Format::Hevc, nvenc::hevc_available); + // HDR sequences export as HEVC Main 10 (PQ / HLG) only where NVENC has a 10-bit encoder; elsewhere + // (and on macOS, whose VideoToolbox path is 8-bit) they are tone-mapped to SDR, as before + filmcraft_export::register_hdr_probe(filmcraft_export::Format::Hevc, nvenc::hevc_hdr_available); + nvenc::warm_hevc_probe(); + static ENCODERS: std::sync::Once = std::sync::Once::new(); + // Export ▸ Hardware encoding (NVENC H.264): in front of the software encoder, taking an + // export only when asked for and when NVENC can do it + ENCODERS.call_once(|| filmcraft_export::register_encoder(nvenc::export::factory)); +} + /// Whether [`register`] has put a hardware decoder factory in front of our decoders. pub fn registered() -> bool { #[cfg(target_os = "macos")] diff --git a/crates/platform/src/nvenc/export.rs b/crates/platform/src/nvenc/export.rs index 4344cb52..a796742f 100644 --- a/crates/platform/src/nvenc/export.rs +++ b/crates/platform/src/nvenc/export.rs @@ -211,8 +211,7 @@ fn config(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettings) - /// The Export encoder factory (see the module documentation). pub fn factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -> Option>> { if format == Format::Hevc { - // H.265 through NVENC is Windows-only for now; on Linux this backend takes H.264 only - return if cfg!(target_os = "windows") { hevc_factory(w, h, rate, s) } else { None }; + return hevc_factory(w, h, rate, s); } factory_with(format, w, h, rate, s, |cfg| open(cfg, &Signal::default())) } @@ -261,7 +260,7 @@ fn factory_with( } } -/// The H.265 side of [`factory`]: FilmCraft's only HEVC encoder on Windows, so the Hardware encoding toggle +/// The H.265 side of [`factory`]: FilmCraft's only HEVC encoder on Windows and Linux, so the Hardware encoding toggle /// does not apply and a request NVENC cannot take is an error, not a fall-through. fn hevc_factory(w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -> Option>> { let declined = |why: &str| { diff --git a/crates/platform/src/nvenc/mod.rs b/crates/platform/src/nvenc/mod.rs index 4fe2357f..d15928e1 100644 --- a/crates/platform/src/nvenc/mod.rs +++ b/crates/platform/src/nvenc/mod.rs @@ -1,5 +1,5 @@ -//! NVIDIA NVENC hardware encoding: H.264 (Windows and 64-bit Linux) and H.265 (HEVC) Main / Main 10 -//! (Windows). +//! NVIDIA NVENC hardware encoding: H.264 and H.265 (HEVC) Main / Main 10 (Windows and +//! 64-bit Linux). //! //! The encoder runs on the GPU's NVENC engine through the driver's `nvEncodeAPI64.dll` on Windows //! (on a Direct3D 11 device) or `libnvidia-encode.so.1` on Linux (on the CUDA driver's primary diff --git a/crates/platform/tests/nvenc_hevc.rs b/crates/platform/tests/nvenc_hevc.rs index acd2bee9..d5f1ca9d 100644 --- a/crates/platform/tests/nvenc_hevc.rs +++ b/crates/platform/tests/nvenc_hevc.rs @@ -2,7 +2,7 @@ //! with our own HEVC decoder to pictures close to the source, keyframes and timestamps are right, the //! samples carry no parameter sets and the `hvcC` describes the SPS it was built from. Skips //! without an NVIDIA GPU / driver with HEVC encoding. -#![cfg(target_os = "windows")] +#![cfg(any(target_os = "windows", all(target_os = "linux", target_pointer_width = "64")))] use filmcraft_bitstream::unescape_rbsp; use filmcraft_hevc::params::Sps; diff --git a/crates/platform/tests/nvenc_hevc_export.rs b/crates/platform/tests/nvenc_hevc_export.rs index 95f074bb..1b449188 100644 --- a/crates/platform/tests/nvenc_hevc_export.rs +++ b/crates/platform/tests/nvenc_hevc_export.rs @@ -4,7 +4,7 @@ //! counters; and what NVENC declines (an error that says why, counted, never a crash). Its own test //! binary: the encoder registry and the counters are process-wide. Skips without an NVIDIA GPU with //! an HEVC encoder. -#![cfg(target_os = "windows")] +#![cfg(any(target_os = "windows", all(target_os = "linux", target_pointer_width = "64")))] use std::path::Path; use std::process::Command; diff --git a/crates/platform/tests/nvenc_hevc_hdr_export.rs b/crates/platform/tests/nvenc_hevc_hdr_export.rs index 8acbc1ca..157e536e 100644 --- a/crates/platform/tests/nvenc_hevc_hdr_export.rs +++ b/crates/platform/tests/nvenc_hevc_hdr_export.rs @@ -5,7 +5,7 @@ //! our importer reads back as PQ / HLG, 10-bit; whose pixels follow a ProRes HDR export of the same //! sequence. SDR stays Main 8-bit BT.709, `settings.sdr` forces it. Its own test binary: the encoder //! registry and the counters are process-wide. Skips (SKIPPED) only without a 10-bit HEVC encoder. -#![cfg(target_os = "windows")] +#![cfg(any(target_os = "windows", all(target_os = "linux", target_pointer_width = "64")))] use std::path::Path; use std::process::Command; diff --git a/crates/platform/tests/nvenc_hevc_main10.rs b/crates/platform/tests/nvenc_hevc_main10.rs index 76d0c9cc..74a8e280 100644 --- a/crates/platform/tests/nvenc_hevc_main10.rs +++ b/crates/platform/tests/nvenc_hevc_main10.rs @@ -3,7 +3,7 @@ //! pictures that come out of our own HEVC decoder are 10-bit and close to them (smooth ramps keep far more //! than 256 levels, bright code values survive), the VUI and the HDR10 SEI messages are right, and //! hostile inputs are errors. Skips (with SKIPPED) only on a machine without a 10-bit HEVC encoder. -#![cfg(target_os = "windows")] +#![cfg(any(target_os = "windows", all(target_os = "linux", target_pointer_width = "64")))] use filmcraft_bitstream::unescape_rbsp; use filmcraft_export::{ColorSignal, rgbf_to_yuv420_10}; diff --git a/crates/platform/tests/nvenc_hevc_probe.rs b/crates/platform/tests/nvenc_hevc_probe.rs index e2a41cd7..28f28f34 100644 --- a/crates/platform/tests/nvenc_hevc_probe.rs +++ b/crates/platform/tests/nvenc_hevc_probe.rs @@ -1,7 +1,7 @@ //! The HEVC availability probe of the NVENC backend: it opens one small encoder session, once. Its own //! test binary so that "first call" means a fresh process (driver load, Direct3D 11 device, NVENC //! session and encoder initialisation all included). -#![cfg(target_os = "windows")] +#![cfg(any(target_os = "windows", all(target_os = "linux", target_pointer_width = "64")))] use std::time::{Duration, Instant}; diff --git a/crates/platform/tests/nvenc_hevc_warm.rs b/crates/platform/tests/nvenc_hevc_warm.rs index adc0ebb2..1a616328 100644 --- a/crates/platform/tests/nvenc_hevc_warm.rs +++ b/crates/platform/tests/nvenc_hevc_warm.rs @@ -1,7 +1,7 @@ //! `register` asks the NVENC HEVC probe on a thread of its own, so the first draw of Export's format //! list does not open an encoder session on the UI thread. Its own test binary: anything else asking //! `hevc_available` first would make the wait meaningless. -#![cfg(target_os = "windows")] +#![cfg(any(target_os = "windows", all(target_os = "linux", target_pointer_width = "64")))] use std::time::{Duration, Instant}; diff --git a/crates/platform/tests/nvenc_rgba_input.rs b/crates/platform/tests/nvenc_rgba_input.rs index 4009f3e6..62b8b9bd 100644 --- a/crates/platform/tests/nvenc_rgba_input.rs +++ b/crates/platform/tests/nvenc_rgba_input.rs @@ -1,7 +1,7 @@ -//! NVENC with packed RGBA input (Windows, NVIDIA GPU): the GPU's RGB → 4:2:0 conversion matches the +//! NVENC with packed RGBA input (Windows and Linux, NVIDIA GPU): the GPU's RGB → 4:2:0 conversion matches the //! export's own BT.709 limited-range conversion (`filmcraft_export::rgba_to_yuv420_8`), for HEVC Main //! and H.264. Skips without an NVIDIA GPU / driver. -#![cfg(target_os = "windows")] +#![cfg(any(target_os = "windows", all(target_os = "linux", target_pointer_width = "64")))] use filmcraft_isobmff::{AvcConfig, SampleEntry}; use filmcraft_platform::nvenc::{Codec, Config, Nvenc, Packet, Profile, Signal, available, hevc_available}; diff --git a/docs/codec-parity.md b/docs/codec-parity.md index 83416bf2..0068c37b 100644 --- a/docs/codec-parity.md +++ b/docs/codec-parity.md @@ -16,7 +16,7 @@ contents). Percent is ready-for-real-work for that codec: depth, profiles, speed | Codec | Premiere | Our decode | Our encode | Fidelity and tests | % | Est. | |---|---|---|---|---|---|---| | 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 | +| 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 and Linux); 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 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 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 29b2f706..b774a538 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -137,8 +137,8 @@ Evidence for the cross-cutting rows: - **Hardware ~45%.** Hardware decode exists on all three desktop OSs (VideoToolbox; Media Foundation / D3D11 incl. VP9 and AV1; VA-API and NVDEC for H.264 / HEVC on Linux). Hardware - encode: VideoToolbox H.264 / HEVC 8-bit; NVENC H.264 (Windows, Linux) and HEVC incl. Main 10 HDR - (Windows). Missing against Premiere: Intel Quick Sync and AMD encoders, 10-bit HEVC on macOS, + encode: VideoToolbox H.264 / HEVC 8-bit; NVENC H.264 and HEVC incl. Main 10 HDR (Windows, + Linux). Missing against Premiere: Intel Quick Sync and AMD encoders, 10-bit HEVC on macOS, ProRes hardware, zero-copy upload, ~48 of Premiere's accelerated effects, external video I/O, control surfaces. Detail: [hardware-parity.md](hardware-parity.md). - **Performance ~55%.** 4K H.264 / HEVC play with no dropped frames with hardware decoding (M4 diff --git a/docs/testing.md b/docs/testing.md index 856dad73..a35f48b4 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -77,7 +77,7 @@ are never linked or shipped ([AGENTS.md](../AGENTS.md) §2). The NVENC tests (`crates/platform/tests/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 +`nvenc_hevc_hdr_export.rs` for Main 10 HDR; Windows and Linux) skip, printing `SKIPPED`, without an NVIDIA GPU with NVENC (H.265: when the HEVC probe says there is no HEVC encoder; Main 10: when the Main 10 probe says there is no 10-bit encoder; on a machine that has one, a configuration NVENC refuses fails the test). The FFI layout tests in `crates/platform/src/nvenc/abi_tests.rs` were generated from a C program built with MSVC From d676fdc7209278f12f928c07dbdcaf26381873d6 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 23:41:36 +0200 Subject: [PATCH 126/144] #424: Render In to Out renders only the frames between In and Out Problem: Render Effects In to Out and Render In to Out rendered every render-bar segment that In/Out touched, in full. Segments run between edit points, so In/Out inside one long clip rendered the whole clip (often the whole timeline). Fix: render only the marked frames of each segment as a partial preview, `--.mov`, frames counted from the segment start (so a ripple keeps it, like whole `.mov` previews). The store indexes parts per hash; the render bar is green over rendered frames only, playback reads the part holding a frame, later renders fill only the gaps, and Delete Render Files In to Out removes only files holding marked frames. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/previews.rs | 308 ++++++++++++++++++++++------ crates/engine/src/previews_tests.rs | 87 +++++++- docs/gaps.md | 8 +- 3 files changed, 325 insertions(+), 78 deletions(-) diff --git a/crates/engine/src/previews.rs b/crates/engine/src/previews.rs index 8533f655..5b75c02f 100644 --- a/crates/engine/src/previews.rs +++ b/crates/engine/src/previews.rs @@ -3,7 +3,13 @@ //! //! Segments, content hashes and the yellow/red cost estimate come from //! [`filmcraft_render::preview`]. A rendered segment is a ProRes 422 QuickTime file named -//! `.mov` in the project's preview cache directory: +//! `.mov` in the project's preview cache directory, and a rendered part of a segment (In/Out +//! inside it: Render In to Out renders only In to Out, like Premiere) is `--.mov`, +//! frames `off..off + len` counted from the segment start. Because the offsets are relative to the +//! segment, a ripple edit that moves the segment keeps its parts, as it keeps whole previews. The +//! render bar is green over the rendered frames only; later renders fill the gaps. +//! +//! The folder: //! //! * saved project `/path/Film.fcproj` → `/path/FilmCraft Previews/Film/`; //! * unsaved project → a per-process folder in the system temp dir; its files move into the @@ -19,7 +25,7 @@ use filmcraft_audio_dsp::channels::{Layout, Mixdown}; use filmcraft_render::audio::to_layout; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, RwLock}; @@ -28,7 +34,7 @@ use filmcraft_frame::{AudioBuffer, VideoFrame}; use filmcraft_media::{FrameRequest, SharedSource}; use filmcraft_project::{ItemId, Project}; use filmcraft_render::preview::{AudioSegment, Need, Segment, segment_at, video_segments}; -use filmcraft_time::{Tick, TimeRange}; +use filmcraft_time::{FrameRate, Tick, TimeRange}; use serde::Serialize; use serde_json::{Value, json}; @@ -73,9 +79,11 @@ pub struct PreviewStore { dir: RwLock>, /// Where unsaved projects' previews go (Settings ▸ Media Cache); None = the system temp dir. temp_root: RwLock>, - /// File names present: `.mov` (video) and `.wav` (audio). + /// File names present: `.mov` / `--.mov` (video) and `.wav` (audio). files: RwLock>, - /// Opened preview files (most recent last). + /// The video files of `files` by segment hash (rebuilt whenever `files` changes). + video: RwLock>>, + /// Opened preview files by file name (most recent last). sources: Mutex>, /// Loaded audio previews: interleaved stereo f32 (most recent last). audio: Mutex>)>>, @@ -90,6 +98,72 @@ pub struct PreviewStore { fn video_name(hash: &str) -> String { format!("{hash}.mov") } + +/// A rendered stretch of one segment: frames `off..off + len` counted from the segment start, in +/// file `name`. A whole-segment preview has `off` 0 and `len` [`WHOLE`]. +#[derive(Clone, Debug, PartialEq, Eq)] +struct Part { + off: i64, + len: i64, + name: String, +} + +const WHOLE: i64 = i64::MAX; + +/// File name of the preview of frames `off..off + len` of a segment of `frames` frames. +fn part_name(hash: &str, off: i64, len: i64, frames: i64) -> String { + if off == 0 && len >= frames { video_name(hash) } else { format!("{hash}-{off}-{len}.mov") } +} + +/// `(hash, off, len)` of a video preview file name (`.mov` or `--.mov`). +fn parse_video_name(name: &str) -> Option<(&str, i64, i64)> { + let stem = name.strip_suffix(".mov")?; + if is_hash(stem) { + return Some((stem, 0, WHOLE)); + } + let mut it = stem.split('-'); + let (hash, off, len) = (it.next()?, it.next()?, it.next()?); + if it.next().is_some() || !is_hash(hash) { + return None; + } + let (off, len) = (off.parse::().ok()?, len.parse::().ok()?); + (len > 0).then_some((hash, i64::from(off), i64::from(len))) +} + +/// The parts of `[a, b)` not covered by the sorted, merged intervals `cov`. +fn gaps(cov: &[(i64, i64)], a: i64, b: i64) -> Vec<(i64, i64)> { + let mut out = Vec::new(); + let mut pos = a; + for &(x, y) in cov { + if y <= pos { + continue; + } + if x >= b { + break; + } + if x > pos { + out.push((pos, x)); + } + pos = pos.max(y); + } + if pos < b { + out.push((pos, b)); + } + out +} + +/// First frame whose start is at or after `t`. +fn frame_ceil(rate: FrameRate, t: Tick) -> i64 { + let f = rate.frame_at(t); + if rate.tick_of(f) < t { f.saturating_add(1) } else { f } +} + +/// Frames of In/Out (`range`) inside `seg`, counted from the segment start (empty when apart). +fn rel_range(seg: &Segment, rate: FrameRate, range: &TimeRange) -> (i64, i64) { + let a = rate.frame_at(range.start).saturating_sub(seg.first_frame).clamp(0, seg.frames.max(0)); + let b = frame_ceil(rate, range.end()).saturating_sub(seg.first_frame).clamp(0, seg.frames.max(0)); + (a, b.max(a)) +} fn audio_name(hash: &str) -> String { format!("{hash}.wav") } @@ -132,15 +206,14 @@ impl PreviewStore { { for e in rd.flatten() { let name = e.file_name().to_string_lossy().to_string(); - if let Some(h) = name.strip_suffix(".mov").or_else(|| name.strip_suffix(".wav")) - && is_hash(h) - { + if parse_video_name(&name).is_some() || name.strip_suffix(".wav").is_some_and(is_hash) { files.insert(name); } } } *self.dir.write().unwrap_or_else(|e| e.into_inner()) = dir; *self.files.write().unwrap_or_else(|e| e.into_inner()) = files; + self.reindex(); self.sources.lock().unwrap_or_else(|e| e.into_inner()).clear(); self.audio.lock().unwrap_or_else(|e| e.into_inner()).clear(); self.bump(); @@ -166,9 +239,47 @@ impl PreviewStore { self.set_dir(Some(dir)); } - /// Whether the video preview of segment `hash` exists. - pub fn has(&self, hash: &str) -> bool { - self.files.read().unwrap_or_else(|e| e.into_inner()).contains(&video_name(hash)) + /// Rebuild the by-hash index of the video previews from the file names. + fn reindex(&self) { + let mut video: HashMap> = HashMap::new(); + for name in self.files.read().unwrap_or_else(|e| e.into_inner()).iter() { + if let Some((hash, off, len)) = parse_video_name(name) { + video.entry(hash.to_string()).or_default().push(Part { off, len, name: name.clone() }); + } + } + *self.video.write().unwrap_or_else(|e| e.into_inner()) = video; + } + + /// The rendered frames of `seg`, counted from its start: sorted, merged, within the segment. + pub fn coverage(&self, seg: &Segment) -> Vec<(i64, i64)> { + let video = self.video.read().unwrap_or_else(|e| e.into_inner()); + let Some(parts) = video.get(&seg.hash) else { return Vec::new() }; + let n = seg.frames.max(0); + let mut iv: Vec<(i64, i64)> = parts.iter().map(|p| (p.off.clamp(0, n), p.off.saturating_add(p.len).clamp(0, n))).filter(|(a, b)| a < b).collect(); + iv.sort_unstable(); + let mut out: Vec<(i64, i64)> = Vec::new(); + for (a, b) in iv { + match out.last_mut() { + Some(l) if a <= l.1 => l.1 = l.1.max(b), + _ => out.push((a, b)), + } + } + out + } + + /// Whether previews cover every frame of `seg`. + pub fn is_rendered(&self, seg: &Segment) -> bool { + matches!(self.coverage(seg).as_slice(), [(0, b)] if *b == seg.frames) + } + + /// Names of the video preview files of `seg` that hold frames of `a..b` (from the segment start). + fn parts_in(&self, seg: &Segment, a: i64, b: i64) -> Vec { + if a >= b { + return Vec::new(); + } + let video = self.video.read().unwrap_or_else(|e| e.into_inner()); + let Some(parts) = video.get(&seg.hash) else { return Vec::new() }; + parts.iter().filter(|p| p.off < b && a < p.off.saturating_add(p.len)).map(|p| p.name.clone()).collect() } /// Whether the audio preview of audio segment `hash` exists. @@ -181,17 +292,14 @@ impl PreviewStore { self.files.read().unwrap_or_else(|e| e.into_inner()).len() } - pub fn path_for(&self, hash: &str) -> Option { - self.dir().map(|d| d.join(video_name(hash))) - } - pub fn audio_path_for(&self, hash: &str) -> Option { self.dir().map(|d| d.join(audio_name(hash))) } - /// Register a finished video preview file. - pub fn add(&self, hash: &str) { - self.files.write().unwrap_or_else(|e| e.into_inner()).insert(video_name(hash)); + /// Register a finished video preview file (`.mov` or `--.mov`). + fn add_video(&self, name: &str) { + self.files.write().unwrap_or_else(|e| e.into_inner()).insert(name.to_string()); + self.reindex(); self.bump(); } @@ -204,23 +312,39 @@ impl PreviewStore { /// Delete the preview files (video and audio) of `hashes`, or every file when None. Returns /// how many files were removed. pub fn delete(&self, hashes: Option<&[String]>) -> usize { - let Some(dir) = self.dir() else { return 0 }; let victims: Vec = { let files = self.files.read().unwrap_or_else(|e| e.into_inner()); match hashes { - Some(h) => h.iter().flat_map(|h| [video_name(h), audio_name(h)]).filter(|n| files.contains(n)).collect(), + Some(h) => files + .iter() + .filter(|n| { + let owner = parse_video_name(n).map(|(hash, _, _)| hash).or_else(|| n.strip_suffix(".wav")); + owner.is_some_and(|o| h.iter().any(|h| h == o)) + }) + .cloned() + .collect(), None => files.iter().cloned().collect(), } }; - let gone = |h: &String| victims.contains(&video_name(h)) || victims.contains(&audio_name(h)); - self.sources.lock().unwrap_or_else(|e| e.into_inner()).retain(|(h, _)| !gone(h)); - self.audio.lock().unwrap_or_else(|e| e.into_inner()).retain(|(h, _)| !gone(h)); + self.delete_files(&victims) + } + + /// Delete the preview files named `names`. Returns how many were removed. + fn delete_files(&self, names: &[String]) -> usize { + let Some(dir) = self.dir() else { return 0 }; + let victims: Vec = { + let files = self.files.read().unwrap_or_else(|e| e.into_inner()); + names.iter().filter(|n| files.contains(*n)).cloned().collect() + }; + self.sources.lock().unwrap_or_else(|e| e.into_inner()).retain(|(n, _)| !victims.contains(n)); + self.audio.lock().unwrap_or_else(|e| e.into_inner()).retain(|(h, _)| !victims.contains(&audio_name(h))); let mut files = self.files.write().unwrap_or_else(|e| e.into_inner()); for n in &victims { let _ = std::fs::remove_file(dir.join(n)); files.remove(n); } drop(files); + self.reindex(); self.bump(); victims.len() } @@ -341,25 +465,41 @@ impl PreviewStore { segs } + /// Green when previews cover the whole segment, else its colour without a preview. pub fn state_of(&self, seg: &Segment) -> BarState { - if self.has(&seg.hash) { - return BarState::Green; - } - match seg.need { - Need::None => BarState::None, - Need::Realtime => BarState::Yellow, - Need::Render => BarState::Red, - } + if self.is_rendered(seg) { BarState::Green } else { need_state(seg) } } - /// The render bar: one span per segment, adjacent spans of the same colour merged. + /// The render bar: per segment, green over its rendered frames and its own colour elsewhere; + /// adjacent spans of the same colour merged. pub fn bar(&self, project: &Arc, seq: ItemId) -> Vec { + let Some(rate) = project.sequence(seq).map(|q| q.settings.frame_rate) else { return Vec::new() }; let mut out: Vec = Vec::new(); for s in self.segments(project, seq).iter() { - let state = self.state_of(s); - match out.last_mut() { - Some(l) if l.end == s.start && l.state == state => l.end = s.end, - _ => out.push(BarSpan { start: s.start, end: s.end, state }), + let need = need_state(s); + let mut spans = Vec::new(); + let mut pos = 0; + for (a, b) in self.coverage(s) { + if a > pos { + spans.push((pos, a, need)); + } + spans.push((a, b, BarState::Green)); + pos = b; + } + if pos < s.frames { + spans.push((pos, s.frames, need)); + } + let at = |f: i64| match f { + 0 => s.start, + f if f >= s.frames => s.end, + f => rate.tick_of(s.first_frame.saturating_add(f)), + }; + for (a, b, state) in spans { + let (start, end) = (at(a), at(b)); + match out.last_mut() { + Some(l) if l.end == start && l.state == state => l.end = end, + _ => out.push(BarSpan { start, end, state }), + } } } out @@ -372,29 +512,33 @@ impl PreviewStore { } let segs = self.segments(project, seq); let seg = segment_at(&segs, frame)?; - if !self.has(&seg.hash) { - return None; - } - let src = self.open(pool, &seg.hash)?; + let rel = frame.checked_sub(seg.first_frame)?; + let (name, off) = { + let video = self.video.read().unwrap_or_else(|e| e.into_inner()); + let p = video.get(&seg.hash)?.iter().find(|p| p.off <= rel && rel < p.off.saturating_add(p.len))?; + (p.name.clone(), p.off) + }; + let src = self.open(pool, &name)?; let rate = project.sequence(seq)?.settings.frame_rate; - src.video_frame(FrameRequest { time: rate.tick_of(frame - seg.first_frame), scale }).ok() + src.video_frame(FrameRequest { time: rate.tick_of(rel - off), scale }).ok() } - fn open(&self, pool: &MediaPool, hash: &str) -> Option { + /// The opened preview file `name` (`.mov` or a part). + fn open(&self, pool: &MediaPool, name: &str) -> Option { { let mut g = self.sources.lock().unwrap_or_else(|e| e.into_inner()); - if let Some(i) = g.iter().position(|(h, _)| h == hash) { + if let Some(i) = g.iter().position(|(n, _)| n == name) { let e = g.remove(i); let s = e.1.clone(); g.push(e); return Some(s); } } - let path = self.path_for(hash)?; + let path = self.dir()?.join(name); let bytes = std::fs::read(&path).ok()?; - let src = pool.open_bytes(&format!("{hash}.mov"), bytes.into()).ok()?; + let src = pool.open_bytes(name, bytes.into()).ok()?; let mut g = self.sources.lock().unwrap_or_else(|e| e.into_inner()); - g.push((hash.to_string(), src.clone())); + g.push((name.to_string(), src.clone())); if g.len() > 6 { g.remove(0); } @@ -402,6 +546,14 @@ impl PreviewStore { } } +fn need_state(seg: &Segment) -> BarState { + match seg.need { + Need::None => BarState::None, + Need::Realtime => BarState::Yellow, + Need::Render => BarState::Red, + } +} + fn is_hash(s: &str) -> bool { s.len() == 32 && s.bytes().all(|b| b.is_ascii_hexdigit()) } @@ -438,33 +590,42 @@ fn in_out(s: &Session) -> Result<(ItemId, TimeRange)> { Ok((id, TimeRange::from_bounds(a, b.max(a)))) } -fn overlaps(seg: &Segment, r: &TimeRange) -> bool { - seg.start < r.end() && r.start < seg.end +/// One preview file to render: frames `off..off + len` of segment `seg` (from its start). +struct Todo { + seg: Segment, + off: i64, + len: i64, } /// Start rendering previews as a background job. `{"wait": true}` renders synchronously. +/// +/// The In/Out modes render only the frames between In and Out (a segment that extends past them +/// gets a partial preview), and every mode renders only frames that have no preview yet. pub fn render(s: &mut Session, mode: RenderMode, p: &Value) -> Result { let (seq, range) = in_out(s)?; + let rate = s.project.sequence(seq).ok_or(EngineError::NoSequence)?.settings.frame_rate; let store = s.previews.clone(); let dir = store.dir().ok_or_else(|| EngineError::Other("render previews are not available here (no preview folder)".into()))?; let segs = store.segments(&s.project, seq); let selection: HashSet<_> = s.state.selection.iter().copied().collect(); - let todo: Vec = segs - .iter() - .filter(|g| !store.has(&g.hash)) - .filter(|g| match mode { - RenderMode::EffectsInToOut => g.need != Need::None && overlaps(g, &range), - RenderMode::InToOut => overlaps(g, &range), - RenderMode::Selection => g.clips.iter().any(|c| selection.contains(c)), - }) - .cloned() - .collect(); + let mut todo: Vec = Vec::new(); + for g in segs.iter() { + let (a, b) = match mode { + RenderMode::EffectsInToOut if g.need == Need::None => continue, + RenderMode::EffectsInToOut | RenderMode::InToOut => rel_range(g, rate, &range), + RenderMode::Selection if g.clips.iter().any(|c| selection.contains(c)) => (0, g.frames.max(0)), + RenderMode::Selection => continue, + }; + for (off, end) in gaps(&store.coverage(g), a, b) { + todo.push(Todo { seg: g.clone(), off, len: end - off }); + } + } if todo.is_empty() { s.toast("Nothing to render: previews are up to date"); return Ok(json!({"job": null, "segments": 0})); } std::fs::create_dir_all(&dir).map_err(|e| EngineError::Other(format!("preview folder {}: {e}", dir.display())))?; - let frames: i64 = todo.iter().map(|g| g.frames).sum(); + let frames: i64 = todo.iter().map(|t| t.len).fold(0, i64::saturating_add); let id = s.jobs.len() as u64 + 1; let job = crate::Job { id, @@ -484,17 +645,19 @@ pub fn render(s: &mut Session, mode: RenderMode, p: &Value) -> Result { let mut bytes = 0u64; let mut done_frames = 0u64; let mut outcome: std::result::Result<(), String> = Ok(()); - for (k, g) in todo.iter().enumerate() { + for (k, t) in todo.iter().enumerate() { if prog.cancel.load(Ordering::Relaxed) { outcome = Err("cancelled".into()); break; } *prog.status.lock().unwrap_or_else(|e| e.into_inner()) = format!("Rendering segment {} of {nseg}", k + 1); - let part = dir.join(format!("{}.mov.part", g.hash)); + let name = part_name(&t.seg.hash, t.off, t.len, t.seg.frames); + let part = dir.join(format!("{name}.part")); + let first = t.seg.first_frame.saturating_add(t.off); let settings = filmcraft_export::ExportSettings { format: filmcraft_export::Format::ProRes, path: part.to_string_lossy().to_string(), - range: Some(TimeRange::from_bounds(g.start, g.end)), + range: Some(TimeRange::from_bounds(rate.tick_of(first), rate.tick_of(first.saturating_add(t.len)))), scale: 1.0, include_audio: false, quality: 90, @@ -510,14 +673,14 @@ pub fn render(s: &mut Session, mode: RenderMode, p: &Value) -> Result { Ok(r) => { bytes += r.bytes; done_frames += r.frames; - let fin = dir.join(format!("{}.mov", g.hash)); + let fin = dir.join(&name); if let Err(e) = std::fs::rename(&part, &fin) { outcome = Err(e.to_string()); break; } - store.add(&g.hash); + store.add_video(&name); // open it now so the first playback doesn't wait for the file read - let _ = store.open(&pool, &g.hash); + let _ = store.open(&pool, &name); } Err(e) => { let _ = std::fs::remove_file(&part); @@ -692,8 +855,18 @@ pub fn read_wav_f32(b: &[u8]) -> Option> { pub fn delete(s: &mut Session, in_to_out: bool) -> Result { let n = if in_to_out { let (seq, range) = in_out(s)?; - let hashes: Vec = s.previews.segments(&s.project, seq).iter().filter(|g| overlaps(g, &range)).map(|g| g.hash.clone()).collect(); - s.previews.delete(Some(&hashes)) + let rate = s.project.sequence(seq).ok_or(EngineError::NoSequence)?.settings.frame_rate; + // the files holding frames between In and Out (a partial preview outside them stays) + let names: Vec = s + .previews + .segments(&s.project, seq) + .iter() + .flat_map(|g| { + let (a, b) = rel_range(g, rate, &range); + s.previews.parts_in(g, a, b) + }) + .collect(); + s.previews.delete_files(&names) } else { s.previews.delete(None) }; @@ -717,6 +890,7 @@ pub fn bar_json(s: &Session) -> Result { "startSeconds": g.start.seconds(), "endSeconds": g.end.seconds(), "state": s.previews.state_of(g), + "renderedFrames": s.previews.coverage(g).iter().map(|(a, b)| b - a).sum::(), "costMs": (g.cost_ms * 10.0).round() / 10.0, "budgetMs": (rate.frame_duration().seconds() * 1000.0 * filmcraft_render::preview::REALTIME_BUDGET * 10.0).round() / 10.0, "hash": g.hash, diff --git a/crates/engine/src/previews_tests.rs b/crates/engine/src/previews_tests.rs index a64a43c8..dfc6b7a4 100644 --- a/crates/engine/src/previews_tests.rs +++ b/crates/engine/src/previews_tests.rs @@ -19,18 +19,16 @@ fn tmp(name: &str) -> std::path::PathBuf { /// A 64×36 24 fps sequence: V1 = matte with Tint (0..24) + plain matte (24..48), A1 = tone (0..48). fn session(name: &str) -> (Session, ClipId) { + session_with(name, Generator::ColorMatte { color: [0.2, 0.5, 0.8, 1.0] }) +} + +/// [`session`] with `video` in place of the matte on V1. +fn session_with(name: &str, video: Generator) -> (Session, ClipId) { let mut s = Session::default(); s.previews.set_dir(Some(tmp(name))); let r = FrameRate::FPS_24; let mut p = (*s.project).clone(); - let matte = crate::demo::add_generator( - &mut p, - &s.media, - GeneratorSource::new(Generator::ColorMatte { color: [0.2, 0.5, 0.8, 1.0] }, 64, 36, r, Tick(10 * TICKS_PER_SECOND)), - "Matte", - Label::Iris, - None, - ); + let matte = crate::demo::add_generator(&mut p, &s.media, GeneratorSource::new(video, 64, 36, r, Tick(10 * TICKS_PER_SECOND)), "Matte", Label::Iris, None); let tone = crate::demo::add_generator( &mut p, &s.media, @@ -135,6 +133,79 @@ fn render_turns_green_edit_invalidates_and_undo_restores() { let _ = std::fs::remove_dir_all(s.previews.dir().unwrap()); } +fn max_diff(a: &[u8], b: &[u8]) -> i32 { + a.iter().zip(b).map(|(a, b)| (*a as i32 - *b as i32).abs()).max().unwrap() +} + +fn set_marks(s: &mut Session, marks: Option<(i64, i64)>) { + let r = FrameRate::FPS_24; + s.edit_sequence("marks", |q, _, _| { + q.mark_in = marks.map(|m| r.tick_of(m.0)); + q.mark_out = marks.map(|m| r.tick_of(m.1)); + Ok(()) + }) + .unwrap(); +} + +/// Render-bar spans as (first frame, end frame, state). +fn spans(s: &Session) -> Vec<(i64, i64, BarState)> { + let r = FrameRate::FPS_24; + s.previews.bar(&s.project, s.state.active_sequence.unwrap()).iter().map(|b| (r.frame_at(b.start), r.frame_at(b.end), b.state)).collect() +} + +/// #424: Render Effects In to Out renders the frames between In and Out, not every segment that +/// In/Out touches; a later render fills only the gaps, and Delete Render Files In to Out removes +/// only the files holding marked frames. +#[test] +fn render_in_to_out_renders_only_the_marked_frames() { + use BarState::{Green, None as NoBar, Yellow}; + let (mut s, _) = session_with("range", Generator::CountingLeader); + let seq = s.state.active_sequence.unwrap(); + set_marks(&mut s, Some((6, 11))); + let v = s.execute("sequence.renderEffectsInToOut", json!({"wait": true})).unwrap(); + job_ok(&s, &v); + assert_eq!(v["frames"], 6, "frames 6..=11, not the whole 24-frame segment: {v}"); + assert_eq!(spans(&s), vec![(0, 6, Yellow), (6, 12, Green), (12, 24, Yellow), (24, 48, NoBar)]); + assert_eq!(states(&s), vec!["yellow", "none"], "a partly rendered segment is not green"); + assert_eq!(crate::previews::bar_json(&s).unwrap()["segments"][0]["renderedFrames"], 6); + for f in [5, 12, 30] { + assert!(s.previews.frame(&s.media, &s.project, seq, f, 1.0).is_none(), "frame {f} has no preview"); + } + // the preview's frames line up with the sequence (the leader changes from frame to frame) + let live = |s: &Session, f: i64| { + filmcraft_render::render_sequence( + &s.project, + seq, + FrameRate::FPS_24.tick_of(f), + Default::default(), + &s.media.provider(s.project.clone(), s.services.clone()), + ) + .over_black_rgba8() + }; + let prev = s.previews.frame(&s.media, &s.project, seq, 9, 1.0).expect("frame 9 has a preview").to_rgba8(); + assert!(max_diff(&live(&s, 9), &live(&s, 3)) > 16, "the test needs frames that differ"); + let d = max_diff(&live(&s, 9), &prev); + assert!(d <= 4, "preview of frame 9 differs from the live frame 9 by {d}"); + // the files survive re-indexing the folder (as on reopening the project) + s.previews.set_dir(s.previews.dir()); + assert_eq!(spans(&s)[1], (6, 12, Green)); + // In/Out over the whole sequence: only the two gaps around the part are rendered + set_marks(&mut s, None); + let v = s.execute("sequence.renderEffectsInToOut", json!({"wait": true})).unwrap(); + job_ok(&s, &v); + assert_eq!((v["segments"].as_i64(), v["frames"].as_i64()), (Some(2), Some(18)), "{v}"); + assert_eq!(states(&s), vec!["green", "none"]); + assert_eq!(s.previews.count(), 3); + let prev = s.previews.frame(&s.media, &s.project, seq, 20, 1.0).expect("frame 20 has a preview").to_rgba8(); + assert!(max_diff(&live(&s, 20), &prev) <= 4); + // Delete Render Files In to Out keeps the parts outside In/Out + set_marks(&mut s, Some((7, 8))); + let v = s.execute("sequence.deleteRenderFilesInToOut", json!({})).unwrap(); + assert_eq!(v["deleted"], 1); + assert_eq!(spans(&s), vec![(0, 6, Green), (6, 12, Yellow), (12, 24, Green), (24, 48, NoBar)]); + let _ = std::fs::remove_dir_all(s.previews.dir().unwrap()); +} + #[test] fn previews_survive_save_and_open() { let (mut s, _) = session("save"); diff --git a/docs/gaps.md b/docs/gaps.md index 3532ae7b..dc486373 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -202,9 +202,11 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** ## G16. Performance reports on 0.5.0 -- **Missing:** steady UI under load. Constant lag after 0.4 → 0.5 (#525), ~100% CPU (#523), - "Render Effects In to Out" (#424). AV1 software decode is ~4 fps at 1080p single-threaded; 8K - is not real time. +- **Missing:** steady UI under load. Constant lag after 0.4 → 0.5 (#525), ~100% CPU (#523). AV1 + software decode is ~4 fps at 1080p single-threaded; 8K is not real time. +- **Done:** Render (Effects) In to Out renders only the frames between In and Out, as partial + previews named by segment hash and relative frames, instead of every segment In/Out touches + (#424); Render Audio still renders whole audio segments. - **Estimate:** 30–60 h. Doc: [performance.md](performance.md). ## G17. Audio device and playback reliability From 2890a42616f66c71abb540c9783b04a150303394 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 04:48:16 +0700 Subject: [PATCH 127/144] fix(menus): show current label colours in Edit menu (#786) --- crates/ui-egui/src/menus.rs | 54 +++++++++++++++++++++++++++++++------ 1 file changed, 46 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 358a6530..7bb32e87 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -778,6 +778,8 @@ pub fn external_commands() -> Vec { pub fn menu_bar(app: &mut FilmcraftApp, ui: &mut egui::Ui) { ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("interface-language"), app.ui.language)); let items = menu_items(app); + // Draw Edit ▸ Label with the user's live palette, not the baked-in label colours. + 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| { @@ -788,7 +790,7 @@ pub fn menu_bar(app: &mut FilmcraftApp, ui: &mut egui::Ui) { if mine.is_empty() { ui.add_enabled(false, egui::Button::new(tl!("(empty)"))); } - menu_level(ui, &mine, 1, &mut clicked, &mut app.auto); + menu_level(ui, &mine, 1, &mut clicked, &mut app.auto, &labels); }); app.auto.add(&format!("menu.{top}"), r.response.rect, top); } @@ -800,7 +802,14 @@ pub fn menu_bar(app: &mut FilmcraftApp, ui: &mut egui::Ui) { /// One menu level: items whose path ends here, and a submenu (at its first item's position) for /// each deeper path segment, recursively (e.g. Clip ▸ Video Options ▸ Time Interpolation). -fn menu_level(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, clicked: &mut Option, auto: &mut crate::automation::Registry) { +fn menu_level( + ui: &mut egui::Ui, + items: &[&MenuItem], + depth: usize, + clicked: &mut Option, + auto: &mut crate::automation::Registry, + labels: &filmcraft_engine::settings::LabelPrefs, +) { let mut subs: Vec<&str> = Vec::new(); for it in items { if let Some(sub) = it.path.get(depth).map(String::as_str) { @@ -812,24 +821,37 @@ fn menu_level(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, clicked: &mu let language = ui.ctx().data(|d| d.get_temp::(egui::Id::new("interface-language"))).unwrap_or_default(); ui.menu_button(language.tr(sub), |ui| { ui.set_min_width(220.0); - menu_level(ui, &inner, depth + 1, clicked, auto); + menu_level(ui, &inner, depth + 1, clicked, auto, labels); }); - } else if menu_entry(ui, it, auto) { + } else if menu_entry(ui, it, auto, labels) { *clicked = Some(it.id.clone()); ui.close(); } } } -fn menu_entry(ui: &mut egui::Ui, it: &MenuItem, auto: &mut crate::automation::Registry) -> bool { - // checkable items leave room for a checkmark drawn at the left - let label = if it.checked.is_some() { format!(" {}", it.label) } else { it.label.clone() }; +/// Only Edit ▸ Label choices get a swatch. Other commands keep their usual menu layout. +fn label_menu_swatch(id: &str, labels: &filmcraft_engine::settings::LabelPrefs) -> Option { + let label = id.strip_prefix("edit.label.").and_then(filmcraft_project::Label::from_name)?; + let [r, g, b] = labels.rgb(label); + Some(egui::Color32::from_rgb(r, g, b)) +} + +fn menu_entry(ui: &mut egui::Ui, it: &MenuItem, auto: &mut crate::automation::Registry, labels: &filmcraft_engine::settings::LabelPrefs) -> bool { + // Checkmarks and label swatches each occupy the same left gutter. + let swatch = label_menu_swatch(&it.id, labels); + let label = if it.checked.is_some() || swatch.is_some() { format!(" {}", it.label) } else { it.label.clone() }; let mut b = egui::Button::new(label); if let Some(s) = &it.shortcut { b = b.shortcut_text(shortcut_text(s)); } let r = ui.add_enabled(it.enabled, b); auto.add(&format!("menu.{}", it.id), r.rect, &it.label); + if let Some(color) = swatch { + let rect = egui::Rect::from_center_size(r.rect.left_center() + egui::vec2(10.0, 0.0), egui::vec2(12.0, 12.0)); + ui.painter().rect_filled(rect, 2.0, color); + ui.painter().rect_stroke(rect, 2.0, egui::Stroke::new(1.0, ui.visuals().widgets.inactive.bg_stroke.color), egui::StrokeKind::Inside); + } if it.checked == Some(true) { let c = r.rect.left_center() + egui::vec2(10.0, 0.0); let col = ui.visuals().text_color(); @@ -842,7 +864,23 @@ fn menu_entry(ui: &mut egui::Ui, it: &MenuItem, auto: &mut crate::automation::Re #[cfg(test)] mod parse_shortcut_tests { - use super::parse_shortcut; + use super::{label_menu_swatch, parse_shortcut}; + + #[test] + fn edit_label_menu_swatch_uses_current_custom_palette() { + use filmcraft_engine::settings::{LabelPrefs, label_id}; + use filmcraft_project::Label; + + let mut labels = LabelPrefs::default(); + let label = Label::Iris; + let key = label_id(label); + labels.colors.get_mut(&key).unwrap().color = "#12ab34".into(); + + assert_eq!(label_menu_swatch("edit.label.Iris", &labels), Some(egui::Color32::from_rgb(0x12, 0xab, 0x34))); + assert_eq!(label_menu_swatch("edit.clear", &labels), None); + assert_eq!(label_menu_swatch("edit.label.invalid", &labels), None); + } + /// Off macOS `Ctrl` and `Cmd` are the same key, so either spelling parses to the modifiers a /// physical Ctrl press carries and matches a binding written the other way (#245). From be98b9d7f799e429581a583c1bd85f4ded20643c Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 23:49:03 +0200 Subject: [PATCH 128/144] #647: decode E-AC-3 (Dolby Digital Plus) audio Problem: files with E-AC-3 audio (MP4 ec-3, MOV, Matroska A_EAC3, MPEG-TS) imported silent: filmcraft-ac3 refused bsid 11-16. Fix: clean-room E-AC-3 decoding from ATSC A/52:2012 Annex E in filmcraft-ac3: bsi and audfrm syntax, 1/2/3/6-block syncframes, reduced sample rates, frame-based exponent strategies (exponents reused across short syncframes), SNR offset strategies, default band structures, spectral extension (translation, notch filter, noise blending). Independent substream 0 is decoded; dependent substreams and other programs are skipped (7.1 plays as its 5.1 core). AHT and enhanced coupling are refused as unsupported. The audio blocks share the AC-3 path; AC-3 output is unchanged. filmcraft-codecs routes E-AC-3 in MP4/MOV, Matroska and TS to the decoder; the TS splitter joins dependent substream frames to their access unit. Tests: ffmpeg oracle for raw E-AC-3 (64.8-91.0 dB SNR) and in MP4, MOV, Matroska and TS (79.5-91.3 dB, random access identical), mutation fuzz, synthetic frames for headers, substream skipping, refusals and exponent reuse across 1-block syncframes, spectral extension unit tests. Co-Authored-By: Claude Opus 5.5 --- crates/ac3/Cargo.toml | 2 +- crates/ac3/README.md | 48 ++- crates/ac3/src/eac3.rs | 634 +++++++++++++++++++++++++++++ crates/ac3/src/lib.rs | 401 ++++++++++++++---- crates/ac3/src/tests.rs | 175 +++++++- crates/ac3/tests/oracle.rs | 132 +++++- crates/codecs/src/audio.rs | 49 ++- crates/codecs/src/mkv.rs | 5 +- crates/codecs/src/mp4.rs | 6 +- crates/codecs/src/mpeg.rs | 4 +- crates/codecs/tests/eac3_oracle.rs | 79 ++++ crates/mpegts/src/es.rs | 46 ++- docs/codec-parity.md | 2 +- docs/gaps.md | 15 +- 14 files changed, 1474 insertions(+), 124 deletions(-) create mode 100644 crates/ac3/src/eac3.rs create mode 100644 crates/codecs/tests/eac3_oracle.rs diff --git a/crates/ac3/Cargo.toml b/crates/ac3/Cargo.toml index e4596596..e7e0149b 100644 --- a/crates/ac3/Cargo.toml +++ b/crates/ac3/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "filmcraft-ac3" -description = "Clean-room AC-3 (Dolby Digital, ATSC A/52) audio decoder" +description = "Clean-room AC-3 and E-AC-3 (Dolby Digital / Dolby Digital Plus, ATSC A/52) audio decoder" version.workspace = true edition.workspace = true license.workspace = true diff --git a/crates/ac3/README.md b/crates/ac3/README.md index 53d2f324..2561540b 100644 --- a/crates/ac3/README.md +++ b/crates/ac3/README.md @@ -1,6 +1,6 @@ # filmcraft-ac3 -Clean-room AC-3 (Dolby Digital) audio decoder in pure Rust. Layer L0: depends only on +Clean-room AC-3 (Dolby Digital) and E-AC-3 (Dolby Digital Plus) audio decoder in pure Rust. Layer L0: depends only on `thiserror`. Builds for `wasm32-unknown-unknown`; no `unsafe`. No GPL/LGPL code (FFmpeg, liba52) was consulted; FFmpeg is used only as an external fixture generator and test oracle. @@ -11,14 +11,17 @@ publicly available standard: §5 bit stream syntax and semantics, §6 decoding o exponents, §7.2 parametric bit allocation (Tables 7.6-7.16 extracted mechanically from the standard's text), §7.3 mantissas and dither, §7.4 coupling, §7.5 rematrixing, §7.7.1 dynamic range, §7.9 transforms and block switching. The window is computed as the Kaiser-Bessel derived -window (α = 5) and checked against Table 7.33 to 6·10⁻⁶. +window (α = 5) and checked against Table 7.33 to 6·10⁻⁶. E-AC-3: Annex E §E2 bit stream syntax +(syncinfo, bsi, audfrm, audblk; Tables E2.1-E2.13, frame exponent strategies from Table E2.10), +§E3.3 modified parameters, §E3.4.2 AHT helper variables (to detect AHT), §E3.6 spectral extension +(attenuation Table E3.14, which is 2^(-(code + 1)(bin + 1) / 15), checked against the table). ## API ```rust let mut dec = filmcraft_ac3::Decoder::new(); let h = filmcraft_ac3::parse_header(frame)?; // rate, channels, frame size -let out = dec.decode(frame)?; // 1536 samples per channel, WAV channel order +let out = dec.decode(frame)?; // h.samples() per channel, WAV channel order dec.reset(); // before decoding from another position ``` @@ -26,7 +29,7 @@ dec.reset(); // before decoding from another - Every audio coding mode (1+1, 1/0, 2/0, 3/0, 2/1, 3/1, 2/2, 3/2) with or without LFE; 32 / 44.1 / 48 kHz and every frame size; bsid ≤ 8 (9 and 10, the half / quarter-rate variants, are - accepted). E-AC-3 (bsid 11-16) is refused with `Error::Unsupported`. + accepted). - D15 / D25 / D45 exponents with reuse across blocks; the parametric bit allocation in the standard's fixed-point steps, delta bit allocation, the all-zero SNR offset special case; grouped 3-, 5- and 11-level mantissas (groups shared across exponent sets), 7- and 15-level @@ -43,6 +46,26 @@ dec.reset(); // before decoding from another - Errors (bad exponents, reserved codes, blocks running past the frame) return `Err`; garbage never panics. +## E-AC-3 (bsid 11-16) + +- Independent substream 0 is decoded (stream types 0 and 2); dependent substreams (the channels + beyond 5.1 of a 7.1 program) and further independent programs decode to no channels, so a 7.1 + stream plays as its 5.1 core. `Header::is_primary` tells them apart. +- 1, 2, 3 or 6 blocks per syncframe (`Header::samples`), 32 / 44.1 / 48 kHz and the reduced rates + 16 / 22.05 / 24 kHz, every acmod with or without LFE. +- The whole bsi (mixing and informational metadata, custom channel maps, addbsi) is parsed and + skipped; audfrm: per-block or frame-based exponent strategies (exponents may be reused across + syncframes of fewer than six blocks; the decoder keeps them until `reset`), the syntax-enable + flags and their defaults, SNR offset strategies 1-3, converter fields, block start information. +- Audio blocks share the AC-3 path: coupling with the default or transmitted band structure + (`firstcplcos` / `firstcplleak` states), rematrixing with the Annex E band count, end mantissas + derived from spectral extension. +- Spectral extension: band structure, coordinates, blending factors, translation with wrap, + banded RMS, the border / wrap-point notch filter, noise blending and scaling (§E3.6.4). +- Refused with `Error::Unsupported`: the adaptive hybrid transform (when a channel uses it; `ahte` + alone is fine) and enhanced coupling. Transient pre-noise processing data is read and not + applied. + ## Accuracy (vs FFmpeg as an external oracle) AC-3 output is not bit-exact across decoders: zero-bit mantissas are filled with @@ -56,10 +79,17 @@ FFmpeg's decode (worst channel SNR): | 1/0 32 kHz 96 kb/s | 94.1 dB | 95.7 dB | | 3/2 + LFE 48 kHz 448 kb/s | 81.4 dB | 90.2 dB | | 2/0 pink noise 128 kb/s (many zero-bit mantissas) | 27.7 dB | 27.9 dB | +| E-AC-3 2/0 48 kHz 192 kb/s (tones, chirp) | 64.8 dB | 74.7 dB | +| E-AC-3 2/0 44.1 kHz 64 kb/s with coupling and rematrixing | 65.5 dB | 66.1 dB | +| E-AC-3 1/0 32 kHz 64 kb/s | 91.0 dB | 90.1 dB | +| E-AC-3 3/2 + LFE 48 kHz 384 kb/s with mixing / informational metadata | 81.6 dB | 85.3 dB | +| E-AC-3 2/0 pink noise 96 kb/s | 48.6 dB | 47.9 dB | The noise row shows the difference is dither: two of our own decodes with different dither sequences differ as much as we differ from FFmpeg. In MPEG-2 TS (AVCHD-style `.mts`) and VOB the -maximum sample difference to FFmpeg is 2.3·10⁻³. +maximum sample difference to FFmpeg is 2.3·10⁻³. E-AC-3 through `filmcraft-codecs` +(`tests/eac3_oracle.rs`): 5.1 in MP4 and Matroska 91.3 dB, stereo in QuickTime 87.0 dB and +MPEG-TS 79.5 dB against FFmpeg's decode. ## Gaps @@ -67,4 +97,10 @@ maximum sample difference to FFmpeg is 2.3·10⁻³. §7.9.4.2 but not checked against an oracle. - No downmixing, no heavy compression (`compr`) or dialogue normalisation (both are metadata for playback systems), no CRC check (corrupt frames are caught by consistency checks). -- E-AC-3 is not decoded. +- FFmpeg's E-AC-3 encoder writes six-block syncframes with frame-based exponent strategies, + coupling with the default band structure and none of the optional per-block syntax. Spectral + extension (no free encoder writes it), fewer blocks per syncframe, the reduced rates and the + per-block syntax (shared with AC-3) are implemented from the standard and covered by unit tests + on synthetic data, not by an oracle. +- E-AC-3: no adaptive hybrid transform, enhanced coupling, transient pre-noise processing, or + channels from dependent substreams (7.1 plays as 5.1). diff --git a/crates/ac3/src/eac3.rs b/crates/ac3/src/eac3.rs new file mode 100644 index 00000000..075f3ddd --- /dev/null +++ b/crates/ac3/src/eac3.rs @@ -0,0 +1,634 @@ +//! E-AC-3 (ATSC A/52:2012 Annex E): the syncframe header and bit stream information (§E2.2.2), +//! the audio frame (§E2.2.3) and spectral extension (§E2.2.4, §E3.6). The audio blocks share the +//! AC-3 decoder (`Decoder::audio_block`), which reads the E-AC-3 differences from [`FrameInfo`]. + +use crate::{Bits, Error, Header, NFCHANS, Result}; + +/// Sample rates by fscod, and by fscod2 when fscod is 3 (Tables E2.2, E2.3). +const RATES: [u32; 3] = [48_000, 44_100, 32_000]; +const REDUCED_RATES: [u32; 3] = [24_000, 22_050, 16_000]; +/// Audio blocks per syncframe by numblkscod (Table E2.4). +const BLOCKS: [u8; 4] = [1, 2, 3, 6]; + +/// Exponent strategies of the six blocks for each frame-based strategy code (Table E2.10): +/// 0 reuse, 1 D15, 2 D25, 3 D45. +const FRAME_EXPSTR: [[u8; 6]; 32] = [ + [1, 0, 0, 0, 0, 0], + [1, 0, 0, 0, 0, 3], + [1, 0, 0, 0, 2, 0], + [1, 0, 0, 0, 3, 3], + [2, 0, 0, 2, 0, 0], + [2, 0, 0, 2, 0, 3], + [2, 0, 0, 3, 2, 0], + [2, 0, 0, 3, 3, 3], + [2, 0, 1, 0, 0, 0], + [2, 0, 2, 0, 0, 3], + [2, 0, 2, 0, 2, 0], + [2, 0, 2, 0, 3, 3], + [2, 0, 3, 2, 0, 0], + [2, 0, 3, 2, 0, 3], + [2, 0, 3, 3, 2, 0], + [2, 0, 3, 3, 3, 3], + [3, 1, 0, 0, 0, 0], + [3, 1, 0, 0, 0, 3], + [3, 2, 0, 0, 2, 0], + [3, 2, 0, 0, 3, 3], + [3, 2, 0, 2, 0, 0], + [3, 2, 0, 2, 0, 3], + [3, 2, 0, 3, 2, 0], + [3, 2, 0, 3, 3, 3], + [3, 3, 1, 0, 0, 0], + [3, 3, 2, 0, 0, 3], + [3, 3, 2, 0, 2, 0], + [3, 3, 2, 0, 3, 3], + [3, 3, 3, 2, 0, 0], + [3, 3, 3, 2, 0, 3], + [3, 3, 3, 3, 2, 0], + [3, 3, 3, 3, 3, 3], +]; + +/// Default coupling band structure by coupling sub-band (Table E2.12, "Default Coupling Banding +/// Structure"; sub-band 0 never starts a combined band). +const DEFAULT_CPLBNDSTRC: [bool; 18] = { + let mut t = [false; 18]; + let ones = [8, 10, 11, 13, 14, 15, 16, 17]; + let mut i = 0; + while i < ones.len() { + t[ones[i]] = true; + i += 1; + } + t +}; + +/// Default spectral extension band structure by sub-band (Table E2.11). +const DEFAULT_SPXBNDSTRC: [bool; 17] = { + let mut t = [false; 17]; + let mut i = 8; + while i < 17 { + t[i] = true; + i += 2; + } + t +}; + +/// First transform coefficient of spectral extension sub-band `k` (Table E3.13, k ≤ 17). +const fn spx_band_bin(k: usize) -> usize { + 25 + 12 * k +} + +/// Spectral extension attenuation (Table E3.14): spxattentab[code][binindex] = 2^(-(code + 1) · +/// (binindex + 1) / 15), which the table lists to nine decimals. +fn spx_atten(code: usize, binindex: usize) -> f32 { + let e = (code as f64 + 1.0) * (binindex as f64 + 1.0) / 15.0; + (-e).exp2() as f32 +} + +/// Parse the header of an E-AC-3 syncframe (bsid 11-16) at the start of `b` (at least 7 bytes). +pub(crate) fn parse_header(b: &[u8], bsid: u8) -> Result
{ + let mut r = Bits::new(b.get(2..).unwrap_or_default()); + let strmtyp = r.read(2) as u8; + let substreamid = r.read(3) as u8; + let words = r.read(11) as usize + 1; + let fscod = r.read(2) as usize; + let (sample_rate, blocks) = match RATES.get(fscod) { + Some(&rate) => (rate, BLOCKS[r.read(2) as usize & 3]), + None => (*REDUCED_RATES.get(r.read(2) as usize).ok_or(Error::Invalid("reserved fscod2"))?, 6), + }; + let acmod = r.read(3) as u8; + let lfeon = r.bit(); + if strmtyp == 3 { + return Err(Error::Unsupported("reserved E-AC-3 stream type 3".into())); + } + let frame_bytes = words * 2; + if frame_bytes < 8 { + return Err(Error::Invalid("E-AC-3 syncframe too short")); + } + // frame bits per second of audio + let bits = frame_bytes as u64 * 8 * u64::from(sample_rate) / (256 * u64::from(blocks)); + let bitrate_kbps = u32::try_from(bits / 1000).unwrap_or(u32::MAX); + Ok(Header { sample_rate, bitrate_kbps, frame_bytes, bsid, acmod, lfeon, blocks, strmtyp, substreamid }) +} + +/// What the audio frame and blocks need from the bit stream information. +pub(crate) struct Bsi { + pub strmtyp: u8, + /// Sample rate code for the bit allocation's hearing threshold: fscod, or fscod2 at the + /// reduced rates. + pub fscod: usize, + pub numblkscod: usize, +} + +/// Read syncinfo and bsi (§E2.2.1-2), leaving `r` at the audio frame. +pub(crate) fn read_bsi(r: &mut Bits) -> Result { + r.skip(16); // syncword + let strmtyp = r.read(2) as u8; + r.skip(3 + 11); // substreamid, frmsiz + let fscod = r.read(2) as usize; + let (rate_code, numblkscod) = if fscod == 3 { + let fscod2 = r.read(2) as usize; + if fscod2 == 3 { + return Err(Error::Invalid("reserved fscod2")); + } + (fscod2, 3) + } else { + (fscod, r.read(2) as usize) + }; + let nblocks = BLOCKS[numblkscod & 3] as usize; + let acmod = r.read(3) as usize; + let lfeon = r.bit(); + r.skip(5 + 5); // bsid, dialnorm + if r.bit() { + r.skip(8); // compr + } + if acmod == 0 { + r.skip(5); // dialnorm2 + if r.bit() { + r.skip(8); // compr2 + } + } + if strmtyp == 1 && r.bit() { + r.skip(16); // chanmap + } + if r.bit() { + // mixing metadata + if acmod > 2 { + r.skip(2); // dmixmod + } + if acmod & 1 != 0 && acmod > 2 { + r.skip(6); // ltrtcmixlev, lorocmixlev + } + if acmod & 4 != 0 { + r.skip(6); // ltrtsurmixlev, lorosurmixlev + } + if lfeon && r.bit() { + r.skip(5); // lfemixlevcod + } + if strmtyp == 0 { + if r.bit() { + r.skip(6); // pgmscl + } + if acmod == 0 && r.bit() { + r.skip(6); // pgmscl2 + } + if r.bit() { + r.skip(6); // extpgmscl + } + match r.read(2) { + 1 => r.skip(5), // premixcmpsel, drcsrc, premixcmpscl + 2 => r.skip(12), + // mixdeflen: the mixdata field (through mixdatafill) is mixdeflen + 2 bytes + 3 => { + let len = r.read(5) as usize; + r.skip(8 * (len + 2)); + } + _ => {} + } + if acmod < 2 { + if r.bit() { + r.skip(14); // panmean, paninfo + } + if acmod == 0 && r.bit() { + r.skip(14); // panmean2, paninfo2 + } + } + if r.bit() { + // frame mixing configuration information + if numblkscod == 0 { + r.skip(5); + } else { + for _ in 0..nblocks { + if r.bit() { + r.skip(5); + } + } + } + } + } + } + if r.bit() { + // informational metadata + r.skip(5); // bsmod, copyrightb, origbs + if acmod == 2 { + r.skip(4); // dsurmod, dheadphonmod + } + if acmod >= 6 { + r.skip(2); // dsurexmod + } + if r.bit() { + r.skip(8); // mixlevel, roomtyp, adconvtyp + } + if acmod == 0 && r.bit() { + r.skip(8); // mixlevel2, roomtyp2, adconvtyp2 + } + if fscod < 3 { + r.skip(1); // sourcefscod + } + } + if strmtyp == 0 && numblkscod != 3 { + r.skip(1); // convsync + } + if strmtyp == 2 && (numblkscod == 3 || r.bit()) { + r.skip(6); // blkid → frmsizecod + } + if r.bit() { + let l = r.read(6) as usize; + r.skip((l + 1) * 8); // addbsi + } + Ok(Bsi { strmtyp, fscod: rate_code, numblkscod }) +} + +/// The audio frame (§E2.2.3) and the state that runs from block to block within a syncframe. +pub(crate) struct FrameInfo { + pub strmtyp: u8, + pub blkswe: bool, + pub dithflage: bool, + pub bamode: bool, + pub frmfgaincode: bool, + pub dbaflde: bool, + pub skipflde: bool, + pub snroffststr: u8, + pub frmcsnroffst: i32, + pub frmfsnroffst: i32, + pub cplstre: [bool; 6], + pub cplinu: [bool; 6], + pub cplexpstr: [u8; 6], + pub chexpstr: [[u8; 5]; 6], + pub lfeexpstr: [u8; 6], + /// Spectral extension attenuation code per channel (spxattencod), when in use. + pub spxatten: [Option; 5], + pub firstcplcos: [bool; 5], + pub firstcplleak: bool, + /// Coupling band structure by absolute coupling sub-band. + pub cplbndstrc: [bool; 18], + pub spx: Spx, +} + +impl FrameInfo { + /// Read the audio frame. `words` is the syncframe size in 16-bit words. + pub(crate) fn read(r: &mut Bits, bsi: &Bsi, acmod: usize, lfeon: bool, nblocks: usize, words: usize) -> Result { + let nf = NFCHANS[acmod & 7]; + let six = bsi.numblkscod == 3; + let (expstre, ahte) = if six { (r.bit(), r.bit()) } else { (true, false) }; + let snroffststr = r.read(2) as u8; + if snroffststr == 3 { + return Err(Error::Invalid("reserved SNR offset strategy")); + } + let transproce = r.bit(); + let mut e = FrameInfo { + strmtyp: bsi.strmtyp, + blkswe: r.bit(), + dithflage: r.bit(), + bamode: r.bit(), + frmfgaincode: r.bit(), + dbaflde: r.bit(), + skipflde: r.bit(), + snroffststr, + frmcsnroffst: 0, + frmfsnroffst: 0, + cplstre: [false; 6], + cplinu: [false; 6], + cplexpstr: [0; 6], + chexpstr: [[0; 5]; 6], + lfeexpstr: [0; 6], + spxatten: [None; 5], + firstcplcos: [true; 5], + firstcplleak: true, + cplbndstrc: DEFAULT_CPLBNDSTRC, + spx: Spx::new(), + }; + let spxattene = r.bit(); + if acmod > 1 { + e.cplstre[0] = true; + e.cplinu[0] = r.bit(); + for blk in 1..nblocks { + e.cplstre[blk] = r.bit(); + e.cplinu[blk] = if e.cplstre[blk] { r.bit() } else { e.cplinu[blk - 1] }; + } + } + if expstre { + for blk in 0..nblocks { + if e.cplinu[blk] { + e.cplexpstr[blk] = r.read(2) as u8; + } + for ch in 0..nf { + e.chexpstr[blk][ch] = r.read(2) as u8; + } + } + } else { + // frame-based strategies (six blocks only) + if acmod > 1 && e.cplinu.iter().any(|&c| c) { + e.cplexpstr = FRAME_EXPSTR[r.read(5) as usize & 31]; + } + for ch in 0..nf { + let s = FRAME_EXPSTR[r.read(5) as usize & 31]; + for (blk, &v) in s.iter().enumerate() { + e.chexpstr[blk][ch] = v; + } + } + } + if lfeon { + for blk in 0..nblocks { + e.lfeexpstr[blk] = r.read(1) as u8; + } + } + if bsi.strmtyp == 0 && (six || r.bit()) { + r.skip(5 * nf); // convexpstr + } + if ahte { + // AHT flags exist for channels whose exponents are sent once in the frame (§E3.4.2) + let unsupported = || Err(Error::Unsupported("E-AC-3 adaptive hybrid transform".into())); + let ncplblks = e.cplinu.iter().filter(|&&c| c).count(); + let ncplregs = (0..6).filter(|&b| e.cplstre[b] || e.cplexpstr[b] != 0).count(); + if ncplblks == 6 && ncplregs == 1 && r.bit() { + return unsupported(); + } + for ch in 0..nf { + if (0..6).filter(|&b| e.chexpstr[b][ch] != 0).count() == 1 && r.bit() { + return unsupported(); + } + } + if lfeon && e.lfeexpstr.iter().filter(|&&s| s != 0).count() == 1 && r.bit() { + return unsupported(); + } + } + if snroffststr == 0 { + e.frmcsnroffst = r.read(6) as i32; + e.frmfsnroffst = r.read(4) as i32; + } + if transproce { + // transient pre-noise processing (§E3.7) is not applied + for _ in 0..nf { + if r.bit() { + r.skip(10 + 8); // transprocloc, transproclen + } + } + } + if spxattene { + for ch in 0..nf { + if r.bit() { + e.spxatten[ch] = Some(r.read(5) as usize); + } + } + } + if bsi.numblkscod != 0 && r.bit() { + // blkstrtinfo: (blocks - 1) × (4 + ceil(log2(words per frame))) bits (§E2.3.2.27) + let log2 = (usize::BITS - words.saturating_sub(1).leading_zeros()) as usize; + r.skip((nblocks - 1) * (4 + log2)); + } + Ok(e) + } +} + +/// Spectral extension parameters (§E2.2.4, §E3.6), carried from block to block of a syncframe. +pub(crate) struct Spx { + pub inu: bool, + pub chinspx: [bool; 5], + pub begf: usize, + strtf: usize, + /// First sub-band and one past the last (spx_begin_subbnd, spx_end_subbnd). + begin: usize, + end: usize, + bndstrc: [bool; 17], + /// Band sizes in coefficients (spxbndsztab) and their count (nspxbnds). + sizes: [usize; 17], + nbands: usize, + first: [bool; 5], + co: [[f32; 17]; 5], + nblend: [[f32; 17]; 5], + sblend: [[f32; 17]; 5], +} + +impl Spx { + fn new() -> Spx { + Spx { + inu: false, + chinspx: [false; 5], + begf: 0, + strtf: 0, + begin: 0, + end: 0, + bndstrc: DEFAULT_SPXBNDSTRC, + sizes: [0; 17], + nbands: 0, + first: [true; 5], + co: [[0.0; 17]; 5], + nblend: [[0.0; 17]; 5], + sblend: [[1.0; 17]; 5], + } + } + + /// First coefficient of the extension region. + pub fn begin_bin(&self) -> usize { + spx_band_bin(self.begin) + } + + /// Read the strategy and coordinates of block `blk`. + pub(crate) fn read(&mut self, r: &mut Bits, blk: usize, acmod: usize, nf: usize) -> Result<()> { + if blk == 0 || r.bit() { + self.inu = r.bit(); + if self.inu { + if acmod == 1 { + self.chinspx = [true, false, false, false, false]; + } else { + for ch in 0..nf { + self.chinspx[ch] = r.bit(); + } + } + self.strtf = r.read(2) as usize; + self.begf = r.read(3) as usize; + let endf = r.read(3) as usize; + self.begin = if self.begf < 6 { self.begf + 2 } else { self.begf * 2 - 3 }; + self.end = if endf < 3 { endf + 5 } else { endf * 2 + 3 }; + if r.bit() { + for b in self.begin + 1..self.end { + self.bndstrc[b] = r.bit(); + } + } + if self.begin >= self.end { + return Err(Error::Invalid("spectral extension ends before it begins")); + } + if self.strtf >= self.begin { + return Err(Error::Invalid("spectral extension copies from above its start")); + } + self.nbands = 1; + self.sizes = [0; 17]; + self.sizes[0] = 12; + for b in self.begin + 1..self.end { + if self.bndstrc[b] { + self.sizes[self.nbands - 1] += 12; + } else { + self.sizes[self.nbands] = 12; + self.nbands += 1; + } + } + } else { + self.chinspx = [false; 5]; + self.first = [true; 5]; + } + } + if !self.inu { + return Ok(()); + } + for ch in 0..nf { + if !self.chinspx[ch] { + self.first[ch] = true; + continue; + } + let coe = std::mem::replace(&mut self.first[ch], false) || r.bit(); + if !coe { + continue; + } + let blend = r.read(5) as f32 / 32.0; + let mstr = r.read(2) as i32; + // coordinates (§E3.6.3) and blending factors (§E3.6.4.2.1) + let mut mant = self.begin_bin() as f32; + let end = spx_band_bin(self.end) as f32; + for b in 0..self.nbands { + let e = r.read(4) as i32; + let m = r.read(2) as f32; + let v = if e == 15 { m / 4.0 } else { (m + 4.0) / 8.0 }; + self.co[ch][b] = v * 2f32.powi(-(e + 3 * mstr)); + let size = self.sizes[b] as f32; + let nratio = ((mant + 0.5 * size) / end - blend).clamp(0.0, 1.0); + self.nblend[ch][b] = nratio.sqrt(); + self.sblend[ch][b] = (1.0 - nratio).sqrt(); + mant += size; + } + } + Ok(()) + } + + /// Synthesize the extension region of one channel's coefficients (§E3.6.4): translate the + /// low band up, notch-filter the borders when attenuation is on, blend with `noise` (zero + /// mean, unit variance) scaled to the band's energy, and scale by the coordinates. + pub(crate) fn synthesize(&self, ch: usize, atten: Option, tc: &mut [f32; 256], mut noise: impl FnMut() -> f32) { + let copystart = spx_band_bin(self.strtf); + let copyend = self.begin_bin(); + let sizes = &self.sizes[..self.nbands]; + // translation + let mut wrap = [false; 17]; + let (mut copy, mut insert) = (copystart, copyend); + for (b, &size) in sizes.iter().enumerate() { + if copy + size > copyend { + copy = copystart; + wrap[b] = true; + } + for _ in 0..size { + if copy == copyend { + copy = copystart; + } + if let (Some(&v), true) = (tc.get(copy), insert < tc.len()) { + tc[insert] = v; + } + insert += 1; + copy += 1; + } + } + // banded RMS energy of the translated coefficients + let mut rms = [0f32; 17]; + let mut at = copyend; + for (b, &size) in sizes.iter().enumerate() { + let band = tc.get(at..at + size).unwrap_or_default(); + rms[b] = (band.iter().map(|v| v * v).sum::() / size as f32).sqrt(); + at += size; + } + // notch filter around the baseband / extension border and each wrap point + if let Some(code) = atten { + let g = [spx_atten(code, 0), spx_atten(code, 1), spx_atten(code, 2)]; + let mut notch = |center: usize| { + for (k, gain) in [g[0], g[1], g[2], g[1], g[0]].into_iter().enumerate() { + if let Some(v) = (center + k).checked_sub(2).and_then(|i| tc.get_mut(i)) { + *v *= gain; + } + } + }; + notch(copyend); + let mut start = copyend; + for (b, &size) in sizes.iter().enumerate() { + if b > 0 && wrap[b] { + notch(start); + } + start += size; + } + } + // noise blending and scaling + let mut at = copyend; + for (b, &size) in sizes.iter().enumerate() { + let nscale = rms[b] * self.nblend[ch][b]; + let sscale = self.sblend[ch][b]; + let co = self.co[ch][b] * 32.0; + for _ in 0..size { + let n = noise(); + if let Some(v) = tc.get_mut(at) { + *v = (*v * sscale + n * nscale) * co; + } + at += 1; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn attenuation_matches_table_e3_14() { + // rows 0, 4, 14 and 31 as printed + let rows: [(usize, [f64; 3]); 4] = [ + (0, [0.954841604, 0.911722489, 0.870550563]), + (4, [0.793700526, 0.629960525, 0.500000000]), + (14, [0.500000000, 0.250000000, 0.125000000]), + (31, [0.227930622, 0.051952369, 0.011841536]), + ]; + for (code, want) in rows { + for (i, w) in want.into_iter().enumerate() { + assert!((f64::from(spx_atten(code, i)) - w).abs() < 1e-7, "{code} {i}"); + } + } + } + + #[test] + fn default_band_structures() { + assert_eq!(DEFAULT_CPLBNDSTRC.iter().filter(|&&b| b).count(), 8); + assert!(DEFAULT_CPLBNDSTRC[8] && !DEFAULT_CPLBNDSTRC[9] && !DEFAULT_CPLBNDSTRC[12] && DEFAULT_CPLBNDSTRC[17]); + assert_eq!(DEFAULT_SPXBNDSTRC.iter().enumerate().filter(|(_, b)| **b).map(|(i, _)| i).collect::>(), vec![8, 10, 12, 14, 16]); + // every frame-based strategy sends new exponents in block 0 + assert!(FRAME_EXPSTR.iter().all(|s| s[0] != 0)); + } + + #[test] + fn spectral_extension_translation_and_scaling() { + // begin at sub-band 2 (bin 49), end at 5 (bin 85), copy from sub-band 0 (bin 25): three + // 12-bin bands, the third wraps back to the copy start + let mut s = Spx::new(); + s.inu = true; + (s.strtf, s.begin, s.end, s.nbands) = (0, 2, 5, 3); + s.sizes[..3].copy_from_slice(&[12, 12, 12]); + s.co[0][..3].copy_from_slice(&[1.0 / 32.0, 0.5 / 32.0, 0.25 / 32.0]); + s.nblend[0] = [0.0; 17]; + s.sblend[0] = [1.0; 17]; + let mut tc = [0f32; 256]; + for (i, v) in tc[25..49].iter_mut().enumerate() { + *v = i as f32 + 1.0; + } + s.synthesize(0, None, &mut tc, || 0.0); + assert_eq!(tc[49], 1.0); + assert_eq!(tc[61], 13.0 * 0.5); + assert_eq!(tc[73], 1.0 * 0.25); + assert_eq!(tc[84], 12.0 * 0.25); + assert_eq!(tc[85], 0.0); + // pure noise: the band gets the energy of its translated coefficients + s.nblend[0] = [1.0; 17]; + s.sblend[0] = [0.0; 17]; + s.co[0] = [1.0 / 32.0; 17]; + let mut tc2 = [0f32; 256]; + tc2[25..49].fill(2.0); + let mut k = 0; + s.synthesize(0, None, &mut tc2, || { + k += 1; + if k % 2 == 0 { 1.0 } else { -1.0 } + }); + assert!(tc2[49..85].iter().all(|v| v.abs() == 2.0)); + } +} diff --git a/crates/ac3/src/lib.rs b/crates/ac3/src/lib.rs index dfb53c5f..acc2dbe4 100644 --- a/crates/ac3/src/lib.rs +++ b/crates/ac3/src/lib.rs @@ -1,5 +1,6 @@ -//! Clean-room AC-3 (Dolby Digital) audio decoder, written from ATSC A/52:2012 ("Digital Audio -//! Compression Standard", §5 bit stream syntax, §6-7 decoding). +//! Clean-room AC-3 (Dolby Digital) and E-AC-3 (Dolby Digital Plus) audio decoder, written from +//! ATSC A/52:2012 ("Digital Audio Compression Standard", §5 bit stream syntax, §6-7 decoding, +//! Annex E for E-AC-3). //! //! - All audio coding modes (1+1, 1/0 … 3/2) with or without LFE, 32 / 44.1 / 48 kHz, every //! frame size (Table 5.18). @@ -11,10 +12,15 @@ //! - Output: planar f32 at full scale ±1, channels in WAV / SMPTE order (L R C LFE Ls Rs); no //! downmix. //! -//! E-AC-3 (bsid 11-16) is recognised and refused. +//! E-AC-3 (bsid 11-16, Annex E): independent substream 0 (dependent substreams and further +//! programs are skipped), 1 / 2 / 3 / 6 blocks per syncframe, the reduced sample rates, frame-based +//! exponent strategies and the other frame-level syntax, and spectral extension (§E3.6). The +//! adaptive hybrid transform and enhanced coupling are refused with [`Error::Unsupported`]; +//! transient pre-noise processing data is read and not applied. #![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))] +mod eac3; mod tables; use std::sync::OnceLock; @@ -47,12 +53,35 @@ pub struct Header { pub bsid: u8, pub acmod: u8, pub lfeon: bool, + /// Audio blocks (of 256 samples per channel) in the syncframe: 6 for AC-3; 1, 2, 3 or 6 for + /// E-AC-3. + pub blocks: u8, + /// E-AC-3 stream type (Table E2.1): 0 independent, 1 dependent, 2 independent converted from + /// AC-3. 0 for AC-3. + pub strmtyp: u8, + /// E-AC-3 substream id (0 for AC-3). + pub substreamid: u8, } impl Header { /// Output channels (full bandwidth + LFE). pub fn channels(&self) -> usize { - NFCHANS[self.acmod as usize] + self.lfeon as usize + NFCHANS[self.acmod as usize & 7] + self.lfeon as usize + } + /// Samples per channel the syncframe decodes to. + pub fn samples(&self) -> usize { + 256 * self.blocks as usize + } + /// Whether this is an E-AC-3 syncframe (bsid 11-16). + pub fn is_eac3(&self) -> bool { + self.bsid > 10 + } + /// Whether the syncframe carries the program [`Decoder::decode`] outputs: AC-3, or E-AC-3 + /// independent substream 0 (§E2.3.1.2). [`Decoder::decode`] returns no channels for the + /// others: dependent substreams (the channels beyond 5.1 of a 7.1 program) and further + /// independent programs. + pub fn is_primary(&self) -> bool { + self.strmtyp != 1 && self.substreamid == 0 } } @@ -64,8 +93,11 @@ pub fn parse_header(b: &[u8]) -> Result
{ let fscod = b[4] >> 6; let frmsizecod = b[4] & 0x3F; let bsid = b[5] >> 3; - if bsid > 10 { - return Err(Error::Unsupported(format!("bsid {bsid} (E-AC-3)"))); + if (11..=16).contains(&bsid) { + return eac3::parse_header(b, bsid); + } + if bsid > 16 { + return Err(Error::Unsupported(format!("bsid {bsid}"))); } if fscod == 3 || frmsizecod >= 38 { return Err(Error::Invalid("reserved fscod / frmsizecod")); @@ -91,7 +123,7 @@ pub fn parse_header(b: &[u8]) -> Result
{ r.skip(2); } let lfeon = r.read(1) == 1; - Ok(Header { sample_rate, bitrate_kbps: kbps >> shift, frame_bytes: words as usize * 2, bsid, acmod, lfeon }) + Ok(Header { sample_rate, bitrate_kbps: kbps >> shift, frame_bytes: words as usize * 2, bsid, acmod, lfeon, blocks: 6, strmtyp: 0, substreamid: 0 }) } /// MSB-first bit reader; reads past the end give zeros. @@ -469,7 +501,9 @@ struct Frame { chincpl: [bool; 5], phsflginu: bool, cplbegf: usize, - cplendf: usize, + /// cplendf + 3: one past the last coupling sub-band (with spectral extension cplendf may be + /// negative, §E3.3.1). + cplend: usize, /// Coupling band of each coupling sub-band (relative to cplbegf). sub_to_band: [usize; 18], ncplbnd: usize, @@ -500,7 +534,7 @@ impl Frame { chincpl: [false; 5], phsflginu: false, cplbegf: 0, - cplendf: 0, + cplend: 0, sub_to_band: [0; 18], ncplbnd: 0, cplco: [[0.0; 18]; 5], @@ -523,7 +557,7 @@ impl Frame { 37 + 12 * self.cplbegf } fn cplendmant(&self) -> usize { - 37 + 12 * (self.cplendf + 3) + 37 + 12 * self.cplend } } @@ -622,6 +656,9 @@ pub struct Decoder { delay: [[f32; 256]; 6], dither: u32, seed: u32, + /// E-AC-3: the block state of the last syncframe (acmod, lfeon, sample rate code), whose + /// exponents a syncframe of fewer than six blocks may reuse. + carry: Option<(usize, bool, usize, Box)>, } impl Default for Decoder { @@ -630,7 +667,8 @@ impl Default for Decoder { } } -/// A decoded syncframe: 1536 samples per channel, WAV channel order. +/// A decoded syncframe: [`Header::samples`] samples per channel (1536 for AC-3), WAV channel +/// order. No channels for an E-AC-3 syncframe that is not [`Header::is_primary`]. #[derive(Clone, Debug)] pub struct Decoded { pub header: Header, @@ -639,7 +677,7 @@ pub struct Decoded { impl Decoder { pub fn new() -> Decoder { - Decoder { delay: [[0.0; 256]; 6], dither: 0, seed: 0x1234_5678 } + Decoder { delay: [[0.0; 256]; 6], dither: 0, seed: 0x1234_5678, carry: None } } /// Seed the dither generator (dither is decoder-specific noise; tests compare two seeds). @@ -650,6 +688,7 @@ impl Decoder { /// Forget the overlap (before decoding from another position). pub fn reset(&mut self) { self.delay = [[0.0; 256]; 6]; + self.carry = None; } fn dither(&mut self) -> f32 { @@ -658,12 +697,20 @@ impl Decoder { ((self.dither >> 8) as f32 / (1u32 << 24) as f32 * 2.0 - 1.0) * 0.707 } + /// Zero-mean, unit-variance noise for spectral extension (§E3.6.4.2.4): uniform in ±√3. + fn noise(&mut self) -> f32 { + self.dither() * (3f32.sqrt() / 0.707) + } + /// Decode one syncframe. pub fn decode(&mut self, frame: &[u8]) -> Result { let h = parse_header(frame)?; if frame.len() < h.frame_bytes { return Err(Error::Invalid("truncated syncframe")); } + if h.is_eac3() { + return self.decode_eac3(&frame[..h.frame_bytes], h); + } let fscod = (frame[4] >> 6) as usize; // Dither is reseeded per syncframe from its CRC words (plus the configured seed), so a // frame decodes to the same samples however it was reached (seeking, render caches). @@ -708,39 +755,83 @@ impl Decoder { let l = r.read(6) as usize; r.skip((l + 1) * 8); } - let nfchans = NFCHANS[acmod]; - let mut f = Frame::new(nfchans, fscod); + let mut f = Frame::new(NFCHANS[acmod], fscod); + let pcm = self.blocks(&mut r, &mut f, acmod, lfeon, 6, None)?; + Ok(Decoded { header: h, channels: wav_order(pcm, acmod, lfeon) }) + } + + /// Decode one E-AC-3 syncframe (`frame` is exactly the syncframe). + fn decode_eac3(&mut self, frame: &[u8], h: Header) -> Result { + if !h.is_primary() { + return Ok(Decoded { header: h, channels: Vec::new() }); + } + // E-AC-3 has a single CRC, at the end (§E2.2.6): reseed the dither from the last words + let n = frame.len(); + let tail = frame.get(n.saturating_sub(4)..).and_then(|t| <[u8; 4]>::try_from(t).ok()).unwrap_or_default(); + self.dither = self.seed ^ u32::from_be_bytes(tail).wrapping_mul(0x9E37_79B9); + let mut r = Bits::new(frame); + let bsi = eac3::read_bsi(&mut r)?; + let (acmod, lfeon) = (h.acmod as usize & 7, h.lfeon); + let nblocks = h.blocks as usize; + let mut e = eac3::FrameInfo::read(&mut r, &bsi, acmod, lfeon, nblocks, n / 2)?; + let mut f = match self.carry.take() { + Some((a, l, fs, f)) if (a, l, fs) == (acmod, lfeon, bsi.fscod) => f, + _ => Box::new(Frame::new(NFCHANS[acmod], bsi.fscod)), + }; + let pcm = self.blocks(&mut r, &mut f, acmod, lfeon, nblocks, Some(&mut e))?; + self.carry = Some((acmod, lfeon, bsi.fscod, f)); + Ok(Decoded { header: h, channels: wav_order(pcm, acmod, lfeon) }) + } + + /// Decode `nblocks` audio blocks and synthesize them (inverse transform, overlap-add), in coded + /// channel order with the LFE last. + fn blocks(&mut self, r: &mut Bits, f: &mut Frame, acmod: usize, lfeon: bool, nblocks: usize, mut x: Option<&mut eac3::FrameInfo>) -> Result>> { + let nfchans = f.nfchans; let nout = nfchans + lfeon as usize; - let mut pcm: Vec> = vec![Vec::with_capacity(1536); nout]; - for blk in 0..6 { + let mut pcm: Vec> = vec![Vec::with_capacity(256 * nblocks); nout]; + for blk in 0..nblocks { let mut coefs = [[0f32; 256]; 6]; - self.audio_block(&mut r, &mut f, acmod, lfeon, blk, &mut coefs)?; + self.audio_block(r, f, acmod, lfeon, blk, &mut coefs, x.as_deref_mut())?; if r.overrun() { return Err(Error::Invalid("audio block runs past the frame")); } // inverse transform, overlap-add let mut buf = [0f32; 512]; - for ch in 0..nout { + for (ch, out) in pcm.iter_mut().enumerate() { let src = if ch < nfchans { ch } else { LFE }; let short = ch < nfchans && f.blksw[ch]; imdct(&coefs[src], short, &mut buf); let d = &mut self.delay[src]; for n in 0..256 { - pcm[ch].push(2.0 * (buf[n] + d[n])); + out.push(2.0 * (buf[n] + d[n])); d[n] = buf[256 + n]; } } } - Ok(Decoded { header: h, channels: wav_order(pcm, acmod, lfeon) }) + Ok(pcm) } - fn audio_block(&mut self, r: &mut Bits, f: &mut Frame, acmod: usize, lfeon: bool, blk: usize, coefs: &mut [[f32; 256]; 6]) -> Result<()> { + #[allow(clippy::too_many_arguments)] + fn audio_block( + &mut self, + r: &mut Bits, + f: &mut Frame, + acmod: usize, + lfeon: bool, + blk: usize, + coefs: &mut [[f32; 256]; 6], + mut x: Option<&mut eac3::FrameInfo>, + ) -> Result<()> { let nf = f.nfchans; - for ch in 0..nf { - f.blksw[ch] = r.bit(); + let eac3 = x.is_some(); + match x.as_deref() { + Some(e) if !e.blkswe => f.blksw = [false; 5], + _ => (0..nf).for_each(|ch| f.blksw[ch] = r.bit()), } - for ch in 0..nf { - f.dithflag[ch] = r.bit(); + match x.as_deref() { + // dither on when the flags are not sent (§E2.2.4) + Some(e) if !e.dithflage => f.dithflag = [true; 5], + _ => (0..nf).for_each(|ch| f.dithflag[ch] = r.bit()), } for k in 0..if acmod == 0 { 2 } else { 1 } { if r.bit() { @@ -749,26 +840,62 @@ impl Decoder { f.dynrng[k] = 1.0; } } + // E-AC-3 spectral extension strategy and coordinates + if let Some(e) = x.as_deref_mut() { + e.spx.read(r, blk, acmod, nf)?; + } + let spx = x.as_deref().map(|e| &e.spx).filter(|s| s.inu); + let spxbegf = spx.map(|s| s.begf); + let spx_begin = spx.map(|s| s.begin_bin()); + let spx_chans = spx.map(|s| s.chinspx).unwrap_or([false; 5]); // coupling strategy - if r.bit() { - f.cplinu = r.bit(); + let strategy = match x.as_deref() { + Some(e) => e.cplstre[blk].then_some(e.cplinu[blk]), + None => r.bit().then(|| r.bit()), + }; + if let Some(inu) = strategy { + f.cplinu = inu; if f.cplinu { - for ch in 0..nf { - f.chincpl[ch] = r.bit(); + if eac3 && r.bit() { + return Err(Error::Unsupported("E-AC-3 enhanced coupling".into())); + } + if eac3 && acmod == 2 { + f.chincpl = [true, true, false, false, false]; + } else { + for ch in 0..nf { + f.chincpl[ch] = r.bit(); + } } if acmod == 2 { f.phsflginu = r.bit(); } f.cplbegf = r.read(4) as usize; - f.cplendf = r.read(4) as usize; - if f.cplendf + 3 <= f.cplbegf { + f.cplend = match spxbegf { + // the coupling region ends where spectral extension begins (§E2.2.4) + Some(b) if b < 6 => b + 1, + Some(b) => 2 * b - 4, + None => r.read(4) as usize + 3, + }; + if f.cplend <= f.cplbegf { return Err(Error::Invalid("coupling end below start")); } - let nsub = 3 + f.cplendf - f.cplbegf; + // coupling band structure, by absolute sub-band + let mut strc = [false; 18]; + match x.as_deref_mut() { + Some(e) => { + if r.bit() { + for s in f.cplbegf + 1..f.cplend { + e.cplbndstrc[s] = r.bit(); + } + } + strc = e.cplbndstrc; + } + None => (f.cplbegf + 1..f.cplend).for_each(|s| strc[s] = r.bit()), + } let mut band = 0; f.sub_to_band[0] = 0; - for s in 1..nsub { - if !r.bit() { + for s in 1..f.cplend - f.cplbegf { + if !strc[f.cplbegf + s] { band += 1; } f.sub_to_band[s] = band; @@ -776,15 +903,33 @@ impl Decoder { f.ncplbnd = band + 1; } else { f.chincpl = [false; 5]; + if let Some(e) = x.as_deref_mut() { + e.firstcplcos = [true; 5]; + e.firstcplleak = true; + f.phsflginu = false; + } } - } else if blk == 0 { + } else if blk == 0 && !eac3 { return Err(Error::Invalid("no coupling strategy in block 0")); } // coupling coordinates if f.cplinu { let mut any = false; for ch in 0..nf { - if f.chincpl[ch] && r.bit() { + if !f.chincpl[ch] { + if let Some(e) = x.as_deref_mut() { + e.firstcplcos[ch] = true; + } + continue; + } + let coe = match x.as_deref_mut() { + Some(e) if e.firstcplcos[ch] => { + e.firstcplcos[ch] = false; + true + } + _ => r.bit(), + }; + if coe { any = true; let mstr = r.read(2) as i32; for bnd in 0..f.ncplbnd { @@ -792,7 +937,7 @@ impl Decoder { let m = r.read(4) as f32; let v = if e == 15 { m / 16.0 } else { (m + 16.0) / 32.0 }; let co = v * 2f32.powi(-(e + 3 * mstr)); - for s in 0..(3 + f.cplendf - f.cplbegf) { + for s in 0..(f.cplend - f.cplbegf) { if f.sub_to_band[s] == bnd { f.cplco[ch][s] = co; } @@ -803,7 +948,7 @@ impl Decoder { if acmod == 2 && f.phsflginu && any { for bnd in 0..f.ncplbnd { let flag = r.bit(); - for s in 0..(3 + f.cplendf - f.cplbegf) { + for s in 0..(f.cplend - f.cplbegf) { if f.sub_to_band[s] == bnd { f.phsflg[s] = flag; } @@ -811,34 +956,55 @@ impl Decoder { } } } - // rematrixing - if acmod == 2 && r.bit() { - let n = if !f.cplinu || f.cplbegf > 2 { - 4 - } else if f.cplbegf > 0 { - 3 + // rematrixing (E-AC-3 always sends the flags in block 0) + if acmod == 2 && ((eac3 && blk == 0) || r.bit()) { + let n = if f.cplinu { + match f.cplbegf { + 0 => 2, + 1 | 2 => 3, + _ => 4, + } } else { - 2 + match spxbegf { + Some(b) if b < 2 => 3, + _ => 4, + } }; f.rematflg = [false; 4]; for k in 0..n { f.rematflg[k] = r.bit(); } } - // exponent strategies - if f.cplinu { - f.expstr[CPL] = r.read(2) as u8; - } - for ch in 0..nf { - f.expstr[ch] = r.read(2) as u8; - } - if lfeon { - f.expstr[LFE] = r.read(1) as u8; + // exponent strategies (E-AC-3: from the audio frame) + match x.as_deref() { + Some(e) => { + if f.cplinu { + f.expstr[CPL] = e.cplexpstr[blk]; + } + f.expstr[..nf].copy_from_slice(&e.chexpstr[blk][..nf]); + if lfeon { + f.expstr[LFE] = e.lfeexpstr[blk]; + } + } + None => { + if f.cplinu { + f.expstr[CPL] = r.read(2) as u8; + } + for ch in 0..nf { + f.expstr[ch] = r.read(2) as u8; + } + if lfeon { + f.expstr[LFE] = r.read(1) as u8; + } + } } for ch in 0..nf { if f.expstr[ch] != 0 { if f.chincpl[ch] && f.cplinu { f.endmant[ch] = f.cplstrtmant(); + } else if let (true, Some(begin)) = (spx_chans[ch], spx_begin) { + // §E3.3.3: the channel ends where spectral extension begins + f.endmant[ch] = begin; } else { let bw = r.read(6) as usize; if bw > 60 { @@ -870,40 +1036,105 @@ impl Decoder { decode_exponents(r, abs, 2, 1, &mut f.exps[LFE], 0, false)?; } // bit allocation parametric information - if r.bit() { - f.g.sdcycod = r.read(2) as usize; - f.g.fdcycod = r.read(2) as usize; - f.g.sgaincod = r.read(2) as usize; - f.g.dbpbcod = r.read(2) as usize; - f.g.floorcod = r.read(3) as usize; - } else if blk == 0 { - return Err(Error::Invalid("no bit allocation information in block 0")); + match x.as_deref() { + Some(e) if !e.bamode => { + // §E2.2.4 defaults + (f.g.sdcycod, f.g.fdcycod, f.g.sgaincod, f.g.dbpbcod, f.g.floorcod) = (2, 1, 1, 2, 7); + } + _ => { + if r.bit() { + f.g.sdcycod = r.read(2) as usize; + f.g.fdcycod = r.read(2) as usize; + f.g.sgaincod = r.read(2) as usize; + f.g.dbpbcod = r.read(2) as usize; + f.g.floorcod = r.read(3) as usize; + } else if blk == 0 && !eac3 { + return Err(Error::Invalid("no bit allocation information in block 0")); + } + } } - if r.bit() { - f.csnroffst = r.read(6) as i32; - if f.cplinu { - f.fsnroffst[CPL] = r.read(4) as i32; - f.fgaincod[CPL] = r.read(3) as usize; + match x.as_deref() { + Some(e) => { + // SNR offset strategies (Table E2.9), fast gain codes + let sets = [CPL, 0, 1, 2, 3, 4, LFE]; + match e.snroffststr { + 0 => { + f.csnroffst = e.frmcsnroffst; + sets.iter().for_each(|&c| f.fsnroffst[c] = e.frmfsnroffst); + } + s if blk == 0 || r.bit() => { + f.csnroffst = r.read(6) as i32; + if s == 1 { + let v = r.read(4) as i32; + sets.iter().for_each(|&c| f.fsnroffst[c] = v); + } else { + if f.cplinu { + f.fsnroffst[CPL] = r.read(4) as i32; + } + for ch in 0..nf { + f.fsnroffst[ch] = r.read(4) as i32; + } + if lfeon { + f.fsnroffst[LFE] = r.read(4) as i32; + } + } + } + _ => {} + } + if e.frmfgaincode && r.bit() { + if f.cplinu { + f.fgaincod[CPL] = r.read(3) as usize; + } + for ch in 0..nf { + f.fgaincod[ch] = r.read(3) as usize; + } + if lfeon { + f.fgaincod[LFE] = r.read(3) as usize; + } + } else { + f.fgaincod = [4; 7]; + } + if e.strmtyp == 0 && r.bit() { + r.skip(10); // convsnroffst + } } - for ch in 0..nf { - f.fsnroffst[ch] = r.read(4) as i32; - f.fgaincod[ch] = r.read(3) as usize; + None => { + if r.bit() { + f.csnroffst = r.read(6) as i32; + if f.cplinu { + f.fsnroffst[CPL] = r.read(4) as i32; + f.fgaincod[CPL] = r.read(3) as usize; + } + for ch in 0..nf { + f.fsnroffst[ch] = r.read(4) as i32; + f.fgaincod[ch] = r.read(3) as usize; + } + if lfeon { + f.fsnroffst[LFE] = r.read(4) as i32; + f.fgaincod[LFE] = r.read(3) as usize; + } + } else if blk == 0 { + return Err(Error::Invalid("no SNR offsets in block 0")); + } } - if lfeon { - f.fsnroffst[LFE] = r.read(4) as i32; - f.fgaincod[LFE] = r.read(3) as usize; - } - } else if blk == 0 { - return Err(Error::Invalid("no SNR offsets in block 0")); } - if f.cplinu && r.bit() { - f.cplleak = (((r.read(3) as i32) << 8) + 768, ((r.read(3) as i32) << 8) + 768); + if f.cplinu { + let leak = match x.as_deref_mut() { + Some(e) if e.firstcplleak => { + e.firstcplleak = false; + true + } + _ => r.bit(), + }; + if leak { + f.cplleak = (((r.read(3) as i32) << 8) + 768, ((r.read(3) as i32) << 8) + 768); + } } // delta bit allocation if blk == 0 { f.deltbae = [2; 7]; } - if r.bit() { + if x.as_deref().is_none_or(|e| e.dbaflde) && r.bit() { if f.cplinu { f.deltbae[CPL] = r.read(2) as u8; } @@ -921,7 +1152,7 @@ impl Decoder { } } // skip field - if r.bit() { + if x.as_deref().is_none_or(|e| e.skipflde) && r.bit() { let l = r.read(9) as usize; r.skip(l * 8); } @@ -1014,6 +1245,16 @@ impl Decoder { } } } + // spectral extension: synthesize the high band of each channel from its low band + if let Some(e) = x.as_deref() + && e.spx.inu + { + for ch in 0..nf { + if e.spx.chinspx[ch] { + e.spx.synthesize(ch, e.spxatten[ch], &mut coefs[ch], || self.noise()); + } + } + } // dynamic range for ch in 0..nf { let g = if acmod == 0 && ch == 1 { f.dynrng[1] } else { f.dynrng[0] }; diff --git a/crates/ac3/src/tests.rs b/crates/ac3/src/tests.rs index d015acbe..c77d97b7 100644 --- a/crates/ac3/src/tests.rs +++ b/crates/ac3/src/tests.rs @@ -56,7 +56,7 @@ fn headers_and_gains() { let b = [0x0B, 0x77, 0, 0, 30, 8 << 3, 0b1110_0001, 0b0100_0000]; let h = parse_header(&b).unwrap(); assert_eq!((h.sample_rate, h.bitrate_kbps, h.frame_bytes, h.acmod, h.lfeon, h.channels()), (48_000, 448, 1792, 7, true, 6)); - assert_eq!(parse_header(&[0x0B, 0x77, 0, 0, 0, 16 << 3, 0, 0]), Err(Error::Unsupported("bsid 16 (E-AC-3)".into()))); + assert_eq!(parse_header(&[0x0B, 0x77, 0, 0, 0, 17 << 3, 0, 0]), Err(Error::Unsupported("bsid 17".into()))); assert_eq!(dynrng_gain(0), 1.0); assert!((dynrng_gain(0b1110_0000) - 0.5).abs() < 1e-7); assert!((dynrng_gain(0b0111_1111) - 16.0 * 63.0 / 64.0).abs() < 1e-5); @@ -96,3 +96,176 @@ fn garbage_never_panics() { } } } + +/// MSB-first bit writer for synthetic E-AC-3 syncframes. +#[derive(Default)] +struct W { + b: Vec, + n: usize, +} + +impl W { + fn put(&mut self, bits: u32, v: u32) { + for i in (0..bits).rev() { + if self.n.is_multiple_of(8) { + self.b.push(0); + } + let bit = ((v >> i) & 1) as u8; + *self.b.last_mut().unwrap() |= bit << (7 - self.n % 8); + self.n += 1; + } + } +} + +/// An E-AC-3 syncframe of `words` 16-bit words: syncinfo and a bsi with no optional metadata +/// (fscod < 3 with `numblkscod`), then `audio` writes the rest; zero padded. +fn eac3_frame(strmtyp: u32, substreamid: u32, words: u32, numblkscod: u32, acmod: u32, audio: impl FnOnce(&mut W)) -> Vec { + let mut w = W::default(); + w.put(16, 0x0B77); + w.put(2, strmtyp); + w.put(3, substreamid); + w.put(11, words - 1); + w.put(2, 0); // 48 kHz + w.put(2, numblkscod); + w.put(3, acmod); + w.put(1, 0); // lfeon + w.put(5, 16); // bsid + w.put(5, 31); // dialnorm + w.put(1, 0); // compre + if strmtyp == 1 { + w.put(1, 0); // chanmape + } + w.put(2, 0); // mixmdate, infomdate + if strmtyp == 0 && numblkscod != 3 { + w.put(1, 0); // convsync + } + w.put(1, 0); // addbsie + audio(&mut w); + let mut b = w.b; + b.resize(words as usize * 2, 0); + b +} + +#[test] +fn eac3_headers() { + let f = eac3_frame(0, 0, 384, 3, 2, |_| {}); + let h = parse_header(&f).unwrap(); + assert_eq!((h.sample_rate, h.blocks, h.samples(), h.frame_bytes, h.bitrate_kbps, h.channels()), (48_000, 6, 1536, 768, 192, 2)); + assert!(h.is_eac3() && h.is_primary()); + // one block per syncframe: 256 samples + let h = parse_header(&eac3_frame(0, 0, 100, 0, 7, |_| {})).unwrap(); + assert_eq!((h.blocks, h.samples(), h.channels(), h.bitrate_kbps), (1, 256, 5, 300)); + // reduced sample rate (fscod 3, fscod2 1): six blocks at 22.05 kHz + let mut f = eac3_frame(0, 0, 384, 0, 2, |_| {}); + f[4] = 0b1101_0100; + let h = parse_header(&f).unwrap(); + assert_eq!((h.sample_rate, h.blocks, h.acmod), (22_050, 6, 2)); + f[4] = 0b1111_0100; + assert_eq!(parse_header(&f), Err(Error::Invalid("reserved fscod2"))); + // a dependent substream and a second independent program are recognised and skipped + let mut d = Decoder::new(); + for (strmtyp, id) in [(1, 0), (0, 1)] { + let f = eac3_frame(strmtyp, id, 64, 3, 2, |_| {}); + let out = d.decode(&f).unwrap(); + assert!(!out.header.is_primary() && out.channels.is_empty()); + } + assert_eq!(parse_header(&eac3_frame(3, 0, 64, 3, 2, |_| {})), Err(Error::Unsupported("reserved E-AC-3 stream type 3".into()))); +} + +#[test] +fn eac3_tools_we_do_not_decode_are_refused() { + // mono, six blocks, AHT enabled and used: chexpstr D15 then reuse, so chahtinu is sent + let aht = eac3_frame(0, 0, 64, 3, 1, |w| { + w.put(1, 1); // expstre + w.put(1, 1); // ahte + w.put(2, 0); // snroffststr + w.put(8, 0); // transproce … spxattene + w.put(2, 1); // chexpstr[0] = D15 + w.put(10, 0); // chexpstr[1..6] = reuse + w.put(5, 0); // convexpstr + w.put(1, 1); // chahtinu + }); + assert_eq!(Decoder::new().decode(&aht).unwrap_err(), Error::Unsupported("E-AC-3 adaptive hybrid transform".into())); + // stereo with coupling in block 0 using enhanced coupling + let ecpl = eac3_frame(0, 0, 64, 3, 2, |w| { + w.put(1, 1); // expstre + w.put(1, 0); // ahte + w.put(2, 0); // snroffststr + w.put(8, 0); + w.put(1, 1); // cplinu[0] + w.put(5, 0); // cplstre[1..6] + w.put(6, 0b01_01_01); // block 0: cplexpstr, chexpstr × 2 + w.put(30, 0); // blocks 1-5: reuse + w.put(10, 0); // convexpstr + w.put(10, 0); // frmcsnroffst, frmfsnroffst + w.put(1, 0); // blkstrtinfoe + w.put(1, 0); // dynrnge + w.put(1, 0); // spxinu + w.put(1, 1); // ecplinu + }); + assert_eq!(Decoder::new().decode(&ecpl).unwrap_err(), Error::Unsupported("E-AC-3 enhanced coupling".into())); +} + +#[test] +fn eac3_single_block_frames_reuse_exponents_across_syncframes() { + // mono, one block per syncframe; the first sends exponents (all 10, bandwidth code 0), the + // second reuses them. Zero SNR offsets allocate no bits: every coefficient is dither. + let frame = |new_exps: bool| { + eac3_frame(0, 0, 32, 0, 1, |w| { + // (expstre and ahte are not sent with fewer than six blocks) + w.put(2, 0); // snroffststr + w.put(8, 0); // transproce … spxattene + w.put(2, new_exps as u32); // chexpstr[0]: D15 or reuse + w.put(1, 0); // convexpstre + w.put(10, 0); // frmcsnroffst, frmfsnroffst + w.put(1, 0); // dynrnge + w.put(1, 0); // spxinu + if new_exps { + w.put(6, 0); // chbwcod → 73 coefficients + w.put(4, 10); // absolute exponent + for _ in 0..24 { + w.put(7, 62); // three zero deltas + } + w.put(2, 0); // gainrng + } + w.put(1, 0); // convsnroffste + }) + }; + let (first, second) = (frame(true), frame(false)); + let mut d = Decoder::new(); + let a = d.decode(&first).unwrap(); + assert_eq!((a.header.samples(), a.channels.len(), a.channels[0].len()), (256, 1, 256)); + let b = d.decode(&second).unwrap(); + let peak = |c: &[f32]| c.iter().fold(0f32, |m, v| m.max(v.abs())); + assert!(peak(&b.channels[0]) > 1e-5 && peak(&b.channels[0]) < 0.1, "{}", peak(&b.channels[0])); + // after a reset there are no exponents to reuse: no coefficients, silence + d.reset(); + let c = d.decode(&second).unwrap(); + assert_eq!(peak(&c.channels[0]), 0.0); +} + +#[test] +fn eac3_garbage_never_panics() { + let mut d = Decoder::new(); + let mut x = 0x5EEDu32; + for len in [8usize, 40, 300, 1500, 4096] { + for _ in 0..300 { + let mut f: Vec = (0..len) + .map(|_| { + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + x as u8 + }) + .collect(); + f[0] = 0x0B; + f[1] = 0x77; + // independent substream 0, frmsiz within the buffer, bsid 11-16 + let words = (len / 2).clamp(4, 2048) - 1; + f[2] = (words >> 8) as u8 & 7; + f[3] = words as u8; + f[5] = (f[5] & 7) | ((11 + x % 6) as u8) << 3; + let _ = d.decode(&f); + } + } +} diff --git a/crates/ac3/tests/oracle.rs b/crates/ac3/tests/oracle.rs index 019d8138..b4d4d067 100644 --- a/crates/ac3/tests/oracle.rs +++ b/crates/ac3/tests/oracle.rs @@ -1,5 +1,5 @@ -//! AC-3 against ffmpeg: ffmpeg's AC-3 encoder writes raw syncframes from generated tones and -//! noise; ffmpeg's decode of the same file is the reference. AC-3 output is not bit-exact across +//! AC-3 and E-AC-3 against ffmpeg: ffmpeg's AC-3 and E-AC-3 encoders write raw syncframes from +//! generated tones and noise; ffmpeg's decode of the same file is the reference. AC-3 output is not bit-exact across //! decoders (zero-bit mantissas are filled with decoder-specific dither, §7.3.4; float vs fixed //! arithmetic), so the criterion is SNR against ffmpeg's decode. @@ -8,6 +8,8 @@ use std::process::{Command, Stdio}; struct Spec { name: &'static str, + /// ffmpeg encoder and raw muxer: "ac3" or "eac3". + codec: &'static str, input: &'static str, rate: u32, channels: u32, @@ -19,6 +21,7 @@ struct Spec { const SPECS: &[Spec] = &[ Spec { name: "ac3_stereo_192k", + codec: "ac3", input: "aevalsrc=exprs=0.3*sin(2*PI*441*t)+0.1*sin(2*PI*3100*t)|0.25*sin(2*PI*660*t)+0.1*sin(2*PI*(500+4000*t)*t):s=48000", rate: 48_000, channels: 2, @@ -28,16 +31,26 @@ const SPECS: &[Spec] = &[ // low bitrate stereo: coupling and rematrixing Spec { name: "ac3_stereo_96k_coupling", + codec: "ac3", input: "aevalsrc=exprs=0.3*sin(2*PI*441*t)+0.05*sin(2*PI*9100*t)|0.3*sin(2*PI*441*t)+0.05*sin(2*PI*11000*t):s=44100", rate: 44_100, channels: 2, enc: &["-b:a", "96k", "-channel_coupling", "1"], min_snr: 40.0, }, - Spec { name: "ac3_mono_32k", input: "sine=frequency=1000:sample_rate=32000", rate: 32_000, channels: 1, enc: &["-b:a", "96k"], min_snr: 60.0 }, + Spec { + name: "ac3_mono_32k", + codec: "ac3", + input: "sine=frequency=1000:sample_rate=32000", + rate: 32_000, + channels: 1, + enc: &["-b:a", "96k"], + min_snr: 60.0, + }, // 5.1 with LFE Spec { name: "ac3_51_448k", + codec: "ac3", input: "aevalsrc=exprs=0.3*sin(2*PI*300*t)|0.3*sin(2*PI*400*t)|0.3*sin(2*PI*500*t)|0.3*sin(2*PI*60*t)|0.2*sin(2*PI*700*t)|0.2*sin(2*PI*800*t):s=48000:c=5.1", rate: 48_000, channels: 6, @@ -47,22 +60,86 @@ const SPECS: &[Spec] = &[ // noise (wideband, many zero-bit mantissas): the SNR is limited by dither Spec { name: "ac3_noise_stereo", + codec: "ac3", input: "anoisesrc=color=pink:sample_rate=48000:amplitude=0.3:seed=3,pan=stereo|c0=c0|c1=-0.5*c0", rate: 48_000, channels: 2, enc: &["-b:a", "128k"], min_snr: 18.0, }, + // E-AC-3 (Annex E): ffmpeg writes six-block syncframes with frame-based exponent strategies + Spec { + name: "eac3_stereo_192k", + codec: "eac3", + input: "aevalsrc=exprs=0.3*sin(2*PI*441*t)+0.1*sin(2*PI*3100*t)|0.25*sin(2*PI*660*t)+0.1*sin(2*PI*(500+4000*t)*t):s=48000", + rate: 48_000, + channels: 2, + enc: &["-b:a", "192k"], + min_snr: 60.0, + }, + // low bitrate stereo: coupling (with the default band structure) and rematrixing + Spec { + name: "eac3_stereo_64k_coupling", + codec: "eac3", + input: "aevalsrc=exprs=0.3*sin(2*PI*441*t)+0.05*sin(2*PI*9100*t)|0.3*sin(2*PI*441*t)+0.05*sin(2*PI*11000*t):s=44100", + rate: 44_100, + channels: 2, + enc: &["-b:a", "64k", "-channel_coupling", "1"], + min_snr: 35.0, + }, + Spec { + name: "eac3_mono_32k", + codec: "eac3", + input: "sine=frequency=1000:sample_rate=32000", + rate: 32_000, + channels: 1, + enc: &["-b:a", "64k"], + min_snr: 60.0, + }, + // 5.1 with LFE and mixing / informational metadata in the bsi + Spec { + name: "eac3_51_384k_metadata", + codec: "eac3", + input: "aevalsrc=exprs=0.3*sin(2*PI*300*t)|0.3*sin(2*PI*400*t)|0.3*sin(2*PI*500*t)|0.3*sin(2*PI*60*t)|0.2*sin(2*PI*700*t)|0.2*sin(2*PI*800*t):s=48000:c=5.1", + rate: 48_000, + channels: 6, + enc: &[ + "-b:a", + "384k", + "-dmix_mode", + "loro", + "-ltrt_cmixlev", + "0.707", + "-loro_surmixlev", + "0.5", + "-dsurex_mode", + "on", + "-mixing_level", + "100", + "-room_type", + "small", + ], + min_snr: 55.0, + }, + Spec { + name: "eac3_noise_stereo", + codec: "eac3", + input: "anoisesrc=color=pink:sample_rate=48000:amplitude=0.3:seed=3,pan=stereo|c0=c0|c1=-0.5*c0", + rate: 48_000, + channels: 2, + enc: &["-b:a", "96k", "-dsur_mode", "on", "-dheadphone_mode", "off"], + min_snr: 18.0, + }, ]; fn make(ff: &Path, s: &Spec) -> Option { - let out = filmcraft_testkit::fixtures_dir("ac3").join(format!("{}.ac3", s.name)); + let out = filmcraft_testkit::fixtures_dir("ac3").join(format!("{}.{}", s.name, s.codec)); filmcraft_testkit::fixtures::generate(&out, |tmp| { let st = Command::new(ff) .args(["-hide_banner", "-v", "error", "-y", "-f", "lavfi", "-i", s.input, "-t", "1"]) - .args(["-c:a", "ac3"]) + .args(["-c:a", s.codec]) .args(s.enc) - .args(["-f", "ac3"]) + .args(["-f", s.codec]) .arg(tmp) .stdin(Stdio::null()) .status(); @@ -94,6 +171,7 @@ fn ffmpeg_encoded_streams_decode() { for fr in frames(&data) { let d = dec.decode(fr).unwrap(); assert_eq!((d.header.sample_rate, d.channels.len() as u32), (s.rate, s.channels), "{}", s.name); + assert_eq!(d.header.is_eac3(), s.codec == "eac3", "{}", s.name); for (o, c) in ours.iter_mut().zip(d.channels) { o.extend(c); } @@ -136,12 +214,52 @@ fn ffmpeg_encoded_streams_decode() { } } +/// Damaged E-AC-3 syncframes (bit flips, truncation, corrupt sizes) give errors or noise, never +/// a panic. +#[test] +fn damaged_eac3_frames_never_panic() { + let ff = filmcraft_testkit::require_ffmpeg!(); + let mut x = 0x00C0_FFEEu32; + let mut rnd = move || { + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + x + }; + for s in SPECS.iter().filter(|s| s.codec == "eac3") { + let f = make(&ff, s).unwrap_or_else(|| panic!("could not generate {}", s.name)); + let data = std::fs::read(&f).unwrap(); + let mut dec = filmcraft_ac3::Decoder::new(); + for fr in frames(&data).into_iter().take(8) { + for k in 0..300 { + let mut m = fr.to_vec(); + match k % 3 { + 0 => { + for _ in 0..1 + rnd() % 8 { + let bit = rnd() as usize % (m.len() * 8); + m[bit / 8] ^= 0x80 >> (bit % 8); + } + } + 1 => m.truncate(rnd() as usize % m.len()), + _ => { + // keep the sync word, scramble frmsiz / fscod / numblkscod / acmod + m[2] ^= rnd() as u8; + m[3] ^= rnd() as u8; + m[4] ^= rnd() as u8; + } + } + let _ = dec.decode(&m); + } + } + } +} + #[test] #[ignore] fn generate_fixtures() { let Some(ff) = filmcraft_testkit::ffmpeg_or_skip("ac3 fixtures") else { return }; for s in SPECS { - let out = filmcraft_testkit::fixtures_dir("ac3").join(format!("{}.ac3", s.name)); + let out = filmcraft_testkit::fixtures_dir("ac3").join(format!("{}.{}", s.name, s.codec)); filmcraft_testkit::fixtures::generate_and_report(&format!("ac3/{}", s.name), &[out], || make(&ff, s)); } } diff --git a/crates/codecs/src/audio.rs b/crates/codecs/src/audio.rs index e364402a..63351636 100644 --- a/crates/codecs/src/audio.rs +++ b/crates/codecs/src/audio.rs @@ -25,7 +25,7 @@ enum Inner { Opus { dec: Box, order: Option<&'static [usize]> }, /// Bootstrap decoders (MP3, ALAC, FLAC, HE-AAC…) via symphonia. Symphonia(Box), - /// Our AC-3 decoder (ATSC A/52). + /// Our AC-3 and E-AC-3 decoder (ATSC A/52, Annex E). Ac3(Box), } @@ -148,7 +148,7 @@ impl PacketDecoder { self.inner = Inner::Aac { dec: Box::new(dec), asc: asc.to_vec(), up: None }; Ok(()) } - /// AC-3 (ATSC A/52). + /// AC-3 and E-AC-3 (ATSC A/52, Annex E). pub fn ac3() -> Result { Ok(Self { inner: Inner::Ac3(Box::new(filmcraft_ac3::Decoder::new())), channels: 0 }) } @@ -161,7 +161,7 @@ impl PacketDecoder { match c { C::Aac(a) => Self::aac(&a.asc, if rate > 0 { rate } else { a.sample_rate }), C::Mp3 => Self::new(CODEC_TYPE_MP3, rate, None), - C::Ac3 { .. } => Self::ac3(), + C::Ac3 { .. } | C::Eac3 { .. } => Self::ac3(), C::Alac { cookie } => Self::new(CODEC_TYPE_ALAC, rate, Some(cookie.clone())), C::Flac(_) => Self::new(CODEC_TYPE_FLAC, rate, None), C::Opus(o) => Self::opus(filmcraft_opus::OpusHead::from_dops(&o.to_bytes()).map_err(|e| CodecError::Unsupported(format!("Opus: {e}")))?), @@ -193,12 +193,16 @@ impl PacketDecoder { Inner::Symphonia(d) => d, Inner::AacPending => return Err(CodecError::Decode("AAC: no configuration yet".into())), Inner::Ac3(d) => { - // a packet may hold several syncframes (MP4 / Matroska) + // a packet may hold several syncframes (MP4 / Matroska); E-AC-3 dependent + // substreams and further programs decode to no channels and are skipped let mut out: Vec> = Vec::new(); let mut p = 0; while p < data.len() { let f = d.decode(&data[p..]).map_err(|e| CodecError::Decode(e.to_string()))?; p += f.header.frame_bytes; + if f.channels.is_empty() { + continue; + } if out.is_empty() { out = f.channels; } else { @@ -279,21 +283,39 @@ pub enum FixedFrames<'a> { Aac(&'a [u8]), /// MPEG-1/2 audio, layer I, II or III. MpegAudio, - /// AC-3 (and E-AC-3). + /// AC-3 (and E-AC-3 in a stream of single syncframes, MPEG-TS). Ac3, + /// E-AC-3 packets of whole syncframes (MP4, Matroska): an access unit may hold several + /// syncframes and dependent substreams. + Eac3, } /// Samples each packet decodes to at the stream's output `rate`, for codecs where that is the same /// for every packet: AAC 1024 (2048 when [`PacketDecoder::aac`] upsamples an HE-AAC core); MPEG -/// audio and AC-3 from the frame header of `first`, the stream's first packet. `None` when it is -/// not known: an AAC configuration our decoder does not read, a header that does not parse, or a -/// header whose rate is not `rate`. +/// audio and AC-3 from the frame header of `first`, the stream's first packet; E-AC-3 from the +/// syncframes of the program we decode in `first`. `None` when it is not known: an AAC +/// configuration our decoder does not read, a header that does not parse, or a header whose rate +/// is not `rate`. pub fn fixed_packet_samples(codec: FixedFrames, first: &[u8], rate: u32) -> Option { let mpegts = match codec { FixedFrames::Aac(asc) => { let core = filmcraft_aac::Decoder::new(asc).ok()?.sample_rate(); return Some(if upsamples_core(core, rate) { 2048 } else { 1024 }); } + FixedFrames::Eac3 => { + let mut samples = 0usize; + let mut p = 0; + while let Some(h) = first.get(p..).and_then(|b| filmcraft_ac3::parse_header(b).ok()) { + if h.is_primary() { + if h.sample_rate != rate { + return None; + } + samples += h.samples(); + } + p += h.frame_bytes; + } + return i64::try_from(samples).ok().filter(|&n| n > 0); + } FixedFrames::MpegAudio => filmcraft_mpegts::Codec::MpegAudio, FixedFrames::Ac3 => filmcraft_mpegts::Codec::Ac3, }; @@ -405,7 +427,7 @@ impl Upsample2x { } } -/// Whether AC-3 audio decodes (our ATSC A/52 decoder). +/// Whether AC-3 and E-AC-3 audio decode (our ATSC A/52 decoder). pub const AC3_DECODER: bool = true; /// An AudioSpecificConfig for AAC with these parameters (ISO/IEC 14496-3 §1.6.2.1, GA specific @@ -967,5 +989,14 @@ mod packet_time_tests { assert_eq!(fixed_packet_samples(FixedFrames::MpegAudio, &[0xFF, 0xFB, 0x90, 0x44], 48_000), None); assert_eq!(fixed_packet_samples(FixedFrames::MpegAudio, &[0xFF], 44_100), None); assert_eq!(fixed_packet_samples(FixedFrames::Ac3, &[0x0B, 0x77], 48_000), None); + // E-AC-3 access units: 8-byte syncframes (frmsiz 3) at 48 kHz, stereo. A six-block + // independent frame with a dependent substream (7.1) is 1536 samples; two one-block + // frames are 512 + let frame = |strmtyp: u8, numblkscod: u8| [0x0B, 0x77, strmtyp << 6, 3, (numblkscod << 4) | (2 << 1), 16 << 3, 0, 0]; + let au = |frames: &[[u8; 8]]| frames.concat(); + assert_eq!(fixed_packet_samples(FixedFrames::Eac3, &au(&[frame(0, 3), frame(1, 3)]), 48_000), Some(1536)); + assert_eq!(fixed_packet_samples(FixedFrames::Eac3, &au(&[frame(0, 0), frame(0, 0)]), 48_000), Some(512)); + assert_eq!(fixed_packet_samples(FixedFrames::Eac3, &au(&[frame(0, 3)]), 44_100), None); + assert_eq!(fixed_packet_samples(FixedFrames::Eac3, &[0x0B, 0x77], 48_000), None); } } diff --git a/crates/codecs/src/mkv.rs b/crates/codecs/src/mkv.rs index 97de6743..9e0b6c0a 100644 --- a/crates/codecs/src/mkv.rs +++ b/crates/codecs/src/mkv.rs @@ -68,7 +68,7 @@ pub struct MkvSource { /// /// Block timestamps are quantised to `TimestampScale` (usually 1 ms), so they are only accurate to a /// tick: a 1024-sample AAC frame at 48 kHz lasts 21.33 ms. Packets that all decode to the same -/// length (AAC, MPEG audio, AC-3) therefore run on from one another, resynchronising to the +/// length (AAC, MPEG audio, AC-3, E-AC-3) therefore run on from one another, resynchronising to the /// timestamp only across gaps of more than half a packet (and more than two ticks); see /// [`crate::audio::contiguous_starts`]. Opus packets: [`opus_starts`]. Other codecs start at their /// timestamps. @@ -86,6 +86,7 @@ fn audio_starts(file: &MkvFile, bytes: &crate::Src, ti: usize, rate: i64) -> Vec Codec::Aac { asc } => fixed_packet_samples(FixedFrames::Aac(asc), &[], out_rate), Codec::Mp3 | Codec::Mp2 => fixed_packet_samples(FixedFrames::MpegAudio, &first(), out_rate), Codec::Ac3 => fixed_packet_samples(FixedFrames::Ac3, &first(), out_rate), + Codec::Eac3 => fixed_packet_samples(FixedFrames::Eac3, &first(), out_rate), _ => None, }; let Some(frame) = frame else { @@ -447,7 +448,7 @@ impl MkvSource { Codec::Opus { head } => PacketDecoder::opus(filmcraft_opus::OpusHead::parse(head).map_err(|e| CodecError::Unsupported(format!("Opus: {e}")))?), Codec::Mp3 => PacketDecoder::new(CODEC_TYPE_MP3, rate, None), Codec::Mp2 => PacketDecoder::mpeg_audio(2, rate), - Codec::Ac3 => PacketDecoder::ac3(), + Codec::Ac3 | Codec::Eac3 => PacketDecoder::ac3(), // symphonia wants the STREAMINFO block body: skip `fLaC` + the 4-byte block header. Codec::Flac { private } => PacketDecoder::new(CODEC_TYPE_FLAC, rate, private.get(8..42).map(<[u8]>::to_vec)), Codec::Vorbis { headers } if headers.len() == 3 => { diff --git a/crates/codecs/src/mp4.rs b/crates/codecs/src/mp4.rs index dffa0dd6..4a3a2c53 100644 --- a/crates/codecs/src/mp4.rs +++ b/crates/codecs/src/mp4.rs @@ -57,8 +57,9 @@ pub fn sniff(b: &[u8]) -> bool { } /// Audio packet start positions in source sample frames at `rate`: the running total of the sample -/// durations, except that packets which all decode to the same length (AAC, MPEG audio, AC-3) run -/// on from one another, resynchronising to the total only across gaps of more than half a packet. +/// durations, except that packets which all decode to the same length (AAC, MPEG audio, AC-3, +/// E-AC-3) run on from one another, resynchronising to the total only across gaps of more than half +/// a packet. /// A remux from Matroska (OBS's) carries the millisecond rounding of its timestamps into the /// durations: 1008, 1008, 1056… for 1024-sample AAC frames at 48 kHz (see /// [`crate::audio::contiguous_starts`]). @@ -81,6 +82,7 @@ fn audio_starts(file: &Mp4File, bytes: &crate::Src, ti: usize, rate: u32) -> Vec Some(CodecConfig::Aac(a)) => fixed_packet_samples(FixedFrames::Aac(&a.asc), &[], rate), Some(CodecConfig::Mp3) => fixed_packet_samples(FixedFrames::MpegAudio, &first(), rate), Some(CodecConfig::Ac3 { .. }) => fixed_packet_samples(FixedFrames::Ac3, &first(), rate), + Some(CodecConfig::Eac3 { .. }) => fixed_packet_samples(FixedFrames::Eac3, &first(), rate), _ => None, }; let Some(frame) = frame else { return stamps }; diff --git a/crates/codecs/src/mpeg.rs b/crates/codecs/src/mpeg.rs index ef2a6999..ca72c88b 100644 --- a/crates/codecs/src/mpeg.rs +++ b/crates/codecs/src/mpeg.rs @@ -120,7 +120,7 @@ fn vkind(c: &Codec) -> Option { } fn audio_supported(c: &Codec) -> bool { - matches!(c, Codec::MpegAudio | Codec::AacAdts | Codec::AacLatm | Codec::LpcmBluray | Codec::LpcmDvd | Codec::Ac3) + matches!(c, Codec::MpegAudio | Codec::AacAdts | Codec::AacLatm | Codec::LpcmBluray | Codec::LpcmDvd | Codec::Ac3 | Codec::Eac3) } /// The coded rate unless the timestamps say otherwise (3:2 pulldown, field-rate coding). Never @@ -505,7 +505,7 @@ impl MpegSource { }; let mut latm = None; let (codec, rate, channels, per_frame, bits) = match &st.codec { - Codec::MpegAudio | Codec::AacAdts | Codec::Ac3 => { + Codec::MpegAudio | Codec::AacAdts | Codec::Ac3 | Codec::Eac3 => { let Some(fi) = filmcraft_mpegts::frame_info(&st.codec, &first) else { return self.audio_unsupported(&codec_name, format!("{codec_name}: bad frame header")); }; diff --git a/crates/codecs/tests/eac3_oracle.rs b/crates/codecs/tests/eac3_oracle.rs new file mode 100644 index 00000000..ee7f335d --- /dev/null +++ b/crates/codecs/tests/eac3_oracle.rs @@ -0,0 +1,79 @@ +//! E-AC-3 (Dolby Digital Plus) audio in MP4, Matroska and MPEG-TS against ffmpeg: ffmpeg encodes +//! 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 common::*; + +const STEREO: &str = "aevalsrc=exprs=0.3*sin(2*PI*441*t)+0.1*sin(2*PI*3100*t)|0.25*sin(2*PI*660*t)+0.1*sin(2*PI*(500+4000*t)*t):s=48000"; +const SURROUND: &str = + "aevalsrc=exprs=0.3*sin(2*PI*300*t)|0.3*sin(2*PI*400*t)|0.3*sin(2*PI*500*t)|0.3*sin(2*PI*60*t)|0.2*sin(2*PI*700*t)|0.2*sin(2*PI*800*t):s=48000:c=5.1"; + +/// Worst channel SNR (dB) of `ours` (planar) against ffmpeg's interleaved `want`, with ours +/// shifted by `lag` samples, over the samples both cover past the first 1000. +fn snr(ours: &[Vec], want: &[f32], ch: usize, lag: i64) -> f64 { + let n = want.len() / ch; + (0..ch) + .map(|c| { + let (mut sig, mut err) = (0f64, 0f64); + for i in 1000..n - 1000 { + let Some(&a) = ours[c].get((i as i64 + lag) as usize) else { continue }; + let (a, b) = (a as f64, want[i * ch + c] as f64); + sig += b * b; + err += (a - b) * (a - b); + } + 10.0 * (sig / err.max(1e-30)).log10() + }) + .fold(f64::INFINITY, f64::min) +} + +#[test] +fn eac3_in_mp4_matroska_and_transport_streams() { + let ff = filmcraft_testkit::require_ffmpeg!(); + let cases: [(&str, &str, u32, &[&str]); 4] = [ + ("eac3_51.mp4", SURROUND, 6, &["-b:a", "384k"]), + ("eac3_51.mkv", SURROUND, 6, &["-b:a", "384k"]), + ("eac3_stereo.mov", STEREO, 2, &["-b:a", "192k"]), + ("eac3_stereo.ts", STEREO, 2, &["-b:a", "192k"]), + ]; + for (name, input, channels, enc) in cases { + let args: Vec<&str> = ["-f", "lavfi", "-i", input, "-t", "2", "-c:a", "eac3"].into_iter().chain(enc.iter().copied()).collect(); + let f = fixture(&ff, name, &args).unwrap_or_else(|| panic!("could not write {name}")); + let src = filmcraft_codecs::open_bytes(name, bytes(&f)).unwrap(); + let a = src.info().audio().cloned().unwrap(); + assert_eq!((a.sample_rate, a.channels), (48_000, channels), "{name}: {}", a.codec); + let want = ffmpeg_audio_f32(&ff, &f, &[]); + let ch = channels as usize; + let n = want.len() / ch; + // MPEG-TS audio starts at its first PTS relative to the presentation start + let start = match name.ends_with(".ts") { + true => filmcraft_codecs::MpegSource::open(name, bytes(&f)).unwrap().audio_extent().map_or(0, |e| e.0), + false => 0, + }; + let ours = src.audio(start, n, 48_000).unwrap().channels; + assert_eq!(ours.len(), ch, "{name}"); + // Matroska stores CodecDelay (the encoder's 256-sample priming) in ns and timestamps in + // ms, and we place packets at the rounded value (240 samples): a shared Matroska timing + // limit (AAC and AC-3 alike), not a decoding one. Elsewhere the samples line up exactly. + let (worst, lag) = (-32..=32).map(|lag| (snr(&ours, &want, ch, lag), lag)).fold((f64::MIN, 0), |a, b| if b.0 > a.0 { b } else { a }); + let max_lag = if name.ends_with(".mkv") { 16 } else { 0 }; + assert!(lag.abs() <= max_lag, "{name}: decode is {lag} samples off ffmpeg's"); + // a window read on a fresh source (random access) gives the same samples + let fresh = filmcraft_codecs::open_bytes(name, bytes(&f)).unwrap(); + let at = n / 3; + let win = fresh.audio(start + at as i64, 4800, 48_000).unwrap().channels; + let mut seek_err = 0f32; + for c in 0..ch { + for (k, v) in win[c].iter().enumerate() { + seek_err = seek_err.max((v - ours[c][at + k]).abs()); + } + } + println!( + "{name}: {} ({} Hz, {ch} ch), worst channel SNR vs ffmpeg {worst:.1} dB (lag {lag}), random access max diff {seek_err:.1e}", + a.codec, a.sample_rate + ); + assert!(worst >= 50.0, "{name}: SNR {worst:.1} dB"); + assert!(seek_err <= 1e-6, "{name}: random-access audio differs by {seek_err}"); + } +} diff --git a/crates/mpegts/src/es.rs b/crates/mpegts/src/es.rs index 7d567969..eba1ffa0 100644 --- a/crates/mpegts/src/es.rs +++ b/crates/mpegts/src/es.rs @@ -235,6 +235,15 @@ pub fn frame_info(codec: &Codec, b: &[u8]) -> Option { } } +/// Whether the syncframe at the start of `b` is E-AC-3 (bsid 11-16) other than independent +/// substream 0: a dependent substream (strmtyp 1) or another program (A/52 §E2.3.1.1-2). +fn eac3_joins_unit(b: &[u8]) -> bool { + match b.get(2..6) { + Some(&[b2, _, _, b5]) => (11..=16).contains(&(b5 >> 3)) && (b2 >> 6 == 1 || (b2 >> 3) & 7 != 0), + _ => false, + } +} + /// Full-bandwidth channels per AC-3 audio coding mode (A/52 Table 5.8). const AC3_CHANNELS: [u32; 8] = [2, 1, 2, 3, 3, 4, 4, 5]; @@ -572,14 +581,18 @@ impl Splitter { } match frame_len(sync, &self.buf[i..]) { Some(len) if self.next_frame == Some(at) || self.confirmed(sync, i, len) => { - self.close(at); - let (pos, offset) = self.locate(at).unwrap_or((u64::MAX, 0)); - let mut o = Open { start: at, pos, offset, pts: None, dts: None, key: true, disposable: false, vcl: false, picture: None }; - if let Some((pts, dts)) = self.timestamps_at(at) { - o.pts = pts; - o.dts = dts; + // an E-AC-3 dependent substream or further program joins the access unit of + // the independent substream 0 before it, as in MP4 and Matroska + if !(sync == AudioSync::Ac3 && self.open.is_some() && eac3_joins_unit(&self.buf[i..])) { + self.close(at); + let (pos, offset) = self.locate(at).unwrap_or((u64::MAX, 0)); + let mut o = Open { start: at, pos, offset, pts: None, dts: None, key: true, disposable: false, vcl: false, picture: None }; + if let Some((pts, dts)) = self.timestamps_at(at) { + o.pts = pts; + o.dts = dts; + } + self.open = Some(o); } - self.open = Some(o); self.next_frame = Some(at + len as u64); if at + len as u64 >= self.buf_start + self.buf.len() as u64 { self.buf.clear(); @@ -654,4 +667,23 @@ mod tests { assert_eq!(ac3_frame_bytes(1, 21), Some(836)); assert_eq!(ac3_frame_bytes(2, 0), Some(192)); } + + #[test] + fn eac3_dependent_substreams_join_the_access_unit() { + // 16-byte E-AC-3 syncframes (frmsiz 7), 48 kHz six blocks stereo: independent, dependent + // (7.1 extension), independent, dependent + let frame = |strmtyp: u8| { + let mut f = vec![0x0B, 0x77, strmtyp << 6, 7, (3 << 4) | (2 << 1), 16 << 3]; + f.resize(16, 0); + f + }; + let es: Vec = [frame(0), frame(1), frame(0), frame(1)].concat(); + let mut s = Splitter::new(&Codec::Eac3); + s.feed(0, &es); + s.finish(); + assert_eq!(s.units.iter().map(|u| u.size).collect::>(), vec![32, 32]); + assert_eq!(frame_info(&Codec::Eac3, &es).map(|f| f.samples), Some(1536)); + // AC-3 frames are never joined + assert!(!eac3_joins_unit(&[0x0B, 0x77, 0x40, 0, 0, 8 << 3])); + } } diff --git a/docs/codec-parity.md b/docs/codec-parity.md index 83416bf2..953cd378 100644 --- a/docs/codec-parity.md +++ b/docs/codec-parity.md @@ -41,7 +41,7 @@ contents). Percent is ready-for-real-work for that codec: depth, profiles, speed | PCM (WAV, BWF, AIFF, MOV, MXF, Blu-ray / DVD LPCM) | yes | yes, BWF timecode | WAV, AIFF, MOV, MXF | sample-exact | 95% | — | | AAC (LC, HE, LATM) | decode + encode | own decoder (LC, HE core), LATM | own LC encoder | loudness oracle vs ffmpeg | 90% | 3–5 h | | AC-3 | decode (licensed) | own decoder (ATSC A/52) | none | conformance | 80% | — | -| **E-AC-3 (Dolby Digital Plus)** | decode | **refused** (bsid 11–16, #647) | none | — | 0% | 15–25 h | +| E-AC-3 (Dolby Digital Plus) | decode | own decoder (ATSC A/52 Annex E, #647): independent substream, 1–6 blocks, spectral extension; **no** adaptive hybrid transform, enhanced coupling or 7.1 dependent substreams (plays the 5.1 core) | none | SNR vs ffmpeg 65–91 dB (raw, MP4, MOV, Matroska, TS) | 75% | 10–20 h | | MP3 / MP2 | decode + MP3 encode | decode (symphonia, MPL-2.0; own MP2) | **none** | — | 60% | 10–20 h (MP3 encoder) | | Opus | — (not a Premiere import format) | own decoder, RFC 8251 range-exact | none | conformance vectors | ahead | — | | FLAC, ALAC, Vorbis | partial | decode (symphonia); FLAC in MP4 silent (#603) | none | — | 70% | 3–6 h | diff --git a/docs/gaps.md b/docs/gaps.md index 3532ae7b..4507944c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -149,12 +149,15 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** ## G10. Container and codec gaps on import -- **Missing:** E-AC-3 (#647; our AC-3 decoder refuses bsid 11–16), AVI (#598; listed as an - extension but no demuxer), WMV / ASF, DV / DVCPRO / DV100, MPEG-4 Part 2, FLAC in MP4 (#603), - camera RAW (R3D incl. R3D NE, ARRIRAW, Sony RAW / X-OCN, Canon RAW, BRAW, ProRes RAW; #345, - #383), JPEG 2000 / JPEG XS MXF, Cinema DNG; stills: PSD, EXR, DPX, Targa, HEIF / HEIC, Radiance - HDR, AI / EPS; transparent video (#402); Sony start timecode in `rtmd` (#460); stereo 3D flag - (#500); MKV multi-audio (#601). +- **Have:** E-AC-3 (Dolby Digital Plus) in MP4, MOV, Matroska and MPEG-TS (#647): independent + substream, 1–6 blocks per syncframe, spectral extension. +- **Missing:** E-AC-3 adaptive hybrid transform, enhanced coupling and the extra channels of 7.1 + dependent substreams (played as the 5.1 core), AVI (#598; listed as an extension but no + demuxer), WMV / ASF, DV / DVCPRO / DV100, MPEG-4 Part 2, FLAC in MP4 (#603), camera RAW (R3D + incl. R3D NE, ARRIRAW, Sony RAW / X-OCN, Canon RAW, BRAW, ProRes RAW; #345, #383), JPEG 2000 / + JPEG XS MXF, Cinema DNG; stills: PSD, EXR, DPX, Targa, HEIF / HEIC, Radiance HDR, AI / EPS; + transparent video (#402); Sony start timecode in `rtmd` (#460); stereo 3D flag (#500); MKV + multi-audio (#601). - **Estimate:** 120–200 h (camera RAW alone 60–100 h, and some SDKs have licences we cannot use: clean-room from public specs only where specs exist). Docs: [codec-parity.md](codec-parity.md), [file-format-parity.md](file-format-parity.md). From e56ddd0e36619d668aff28227ea1c8b21208c66e Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 23:50:15 +0200 Subject: [PATCH 129/144] #780: Optical Flow time interpolation synthesises motion-compensated frames Problem: a speed-changed clip set to Time Interpolation > Optical Flow rendered exactly like Frame Blending, a cross-fade of the two neighbouring source frames, so moving objects showed double images. Fix: new filmcraft_render::flow. It computes dense coarse-to-fine Lucas-Kanade flow both ways on a luma pyramid at most 640 px wide, drops motion wherever standing still explains the window as well (flicker on static content), forward-splats both flows to the in-between time (Baker et al. 2011), and warps both frames there. Each fetch is weighted by how well its own flow agrees, so uncovered background comes from the frame that shows it. Pixels that look the same in both frames, or where neither fetch agrees, fade to the plain cross-fade. media_image uses it for OpticalFlow clips. docs/gaps.md G18 and target-app-parity updated (also drops the #415 / #458 items, already fixed on main). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/clip_ops_tests.rs | 8 +- crates/render/src/flow.rs | 625 ++++++++++++++++++++++++++++ crates/render/src/lib.rs | 12 +- crates/render/src/track.rs | 2 +- docs/gaps.md | 10 +- docs/target-app-parity.md | 2 +- 6 files changed, 645 insertions(+), 14 deletions(-) create mode 100644 crates/render/src/flow.rs diff --git a/crates/engine/src/clip_ops_tests.rs b/crates/engine/src/clip_ops_tests.rs index 11707e25..68eb470f 100644 --- a/crates/engine/src/clip_ops_tests.rs +++ b/crates/engine/src/clip_ops_tests.rs @@ -510,10 +510,14 @@ fn frame_blending_renders_in_between_frames() { *a = (*a + b) * 0.5; } assert!(mean_abs_diff(&avg, &blended) < mean_abs_diff(&sampled, &blended), "closer to the average than to either frame"); - // optical flow falls back to blending + // optical flow synthesises a motion-compensated frame instead of the cross-fade s.execute("clip.timeInterpolation.opticalFlow", json!({})).unwrap(); s.execute("playhead.set", json!({"time": t.0})).unwrap(); - assert!(mean_abs_diff(&s.render_program(0.125).unwrap(), &blended) < 1e-6); + let flowed = s.render_program(0.125).unwrap(); + // the moving traffic is motion compensated, the static city stays as blended + let d = mean_abs_diff(&flowed, &blended); + assert!(d > 1e-6, "optical flow should not be the plain cross-fade: {d}"); + assert!(d < mean_abs_diff(&blended, &sampled), "optical flow stays near the blend on mostly static footage: {d}"); // at 100 % nothing is blended s.execute("clip.speedDuration", json!({"speed": 100.0})).unwrap(); assert!(filmcraft_render::interpolation_blend(&clip(&s, c.id.0), t, src_rate).is_none()); diff --git a/crates/render/src/flow.rs b/crates/render/src/flow.rs new file mode 100644 index 00000000..1d80ba13 --- /dev/null +++ b/crates/render/src/flow.rs @@ -0,0 +1,625 @@ +//! Optical Flow time interpolation: the in-between frame of a speed-changed clip synthesised from +//! the motion between its two neighbouring source frames, so a moving object appears once, at its +//! interpolated position, instead of as the double image of Frame Blending. +//! +//! The method (textbook, implemented from the published equations): +//! +//! 1. **Dense flow** — Lucas & Kanade (1981) solved at every pixel: the gradient structure tensor +//! and the mismatch term summed over a (2·[`WIN`]+1)² window (box filter), Gauss–Newton iterated +//! with bilinear warping, Tikhonov-damped so flat areas keep the coarser estimate, coarse to fine +//! over a binomial pyramid of luma at a working width of at most [`WORK_W`] pixels. After each +//! level, motion is dropped wherever standing still matches the window as well (brightness +//! changes on static content are not motion), then a 3×3 median removes outliers. Computed both +//! ways (frame 0 → 1 and 1 → 0). +//! 2. **Flow at the in-between time** — forward splatting (the interpolation of Baker et al., "A +//! Database and Evaluation Methodology for Optical Flow", IJCV 2011): every pixel of frame 0 +//! lands at `p + t·u`, every pixel of frame 1 at `p + (1−t)·u'`; where several land on one +//! pixel the one whose flow is forward–backward consistent and photometrically best wins; holes +//! are filled from their neighbours. +//! 3. **Warp and blend** — each output pixel fetches frame 0 at `x − t·u` and frame 1 at +//! `x + (1−t)·u`, weighted `(1−t, t)` times how well the fetched pixel's own flow agrees with +//! `u` (a pixel that moves elsewhere is hidden at time `t`), so background an object uncovers +//! comes from the frame that shows it. Where neither fetch agrees, or the pixel looks the same in +//! both frames, it fades to the plain cross-fade (Frame Blending). +//! +//! Everything is deterministic (no randomness, per-pixel parallel work only). + +use rayon::prelude::*; + +use crate::image::Image; +use crate::track::Gray; + +/// Widest luma image the flow is estimated on (larger frames are box-reduced to it first). +pub const WORK_W: usize = 640; +/// Window radius of the per-pixel Lucas–Kanade sums. +pub const WIN: usize = 4; +/// Pyramid levels stop above this size (either dimension). +const MIN_LEVEL: usize = 12; +const MAX_LEVELS: usize = 7; +/// Gauss–Newton iterations per pyramid level. +const ITERS: usize = 6; +/// Gradient noise floor (square-root luma per pixel): texture weaker than this, summed over the +/// window, is not trusted to show motion. Damps the structure tensor (Tikhonov). +const NOISE: f32 = 0.01; +const DAMP: f32 = ((2 * WIN + 1) * (2 * WIN + 1)) as f32 * NOISE * NOISE; +/// Largest flow update per iteration (level pixels). +const MAX_STEP: f32 = 2.0; +/// Weight of the forward–backward error (pixels) against the luma error when splats collide. +const FB_WEIGHT: f32 = 0.05; +/// Flow disagreement (working pixels) at which a fetch counts half. +const AGREE_PX: f32 = 1.0; +/// Luma change (square-root luma) below which a pixel counts as unchanged between the frames; +/// the motion-compensated result fades in over the next `STILL_LUMA`. +const STILL_LUMA: f32 = 0.004; +/// Hole-filling passes after splatting (remaining holes keep zero motion). +const FILL_PASSES: usize = 64; + +/// A dense flow field on a working-resolution grid (pixel centres at integers). +#[derive(Clone, Debug, PartialEq)] +struct Flow { + w: usize, + h: usize, + uv: Vec<[f32; 2]>, +} + +impl Flow { + fn zero(w: usize, h: usize) -> Self { + Self { w, h, uv: vec![[0.0; 2]; w.saturating_mul(h)] } + } + #[inline] + fn at(&self, x: i64, y: i64) -> [f32; 2] { + if self.w == 0 || self.h == 0 { + return [0.0; 2]; + } + let x = x.clamp(0, self.w as i64 - 1) as usize; + let y = y.clamp(0, self.h as i64 - 1) as usize; + self.uv.get(y * self.w + x).copied().unwrap_or([0.0; 2]) + } + /// Bilinear sample, clamped at the edges. + #[inline] + fn sample(&self, x: f32, y: f32) -> [f32; 2] { + let (x, y) = (if x.is_finite() { x } else { 0.0 }, if y.is_finite() { y } else { 0.0 }); + let (x0, y0) = (x.floor(), y.floor()); + let (fx, fy) = (x - x0, y - y0); + let (x0, y0) = (x0 as i64, y0 as i64); + let (a, b, c, d) = (self.at(x0, y0), self.at(x0 + 1, y0), self.at(x0, y0 + 1), self.at(x0 + 1, y0 + 1)); + let mut o = [0.0; 2]; + for k in 0..2 { + let top = a[k] + (b[k] - a[k]) * fx; + let bot = c[k] + (d[k] - c[k]) * fx; + o[k] = top + (bot - top) * fy; + } + o + } +} + +/// Clamped fetch from a luma image. +#[inline] +fn g_at(g: &Gray, x: i64, y: i64) -> f32 { + if g.w == 0 || g.h == 0 { + return 0.0; + } + let x = x.clamp(0, g.w as i64 - 1) as usize; + let y = y.clamp(0, g.h as i64 - 1) as usize; + g.px.get(y * g.w + x).copied().unwrap_or(0.0) +} + +/// Bilinear luma sample (pixel centres at integers), clamped; non-finite coordinates read the origin. +#[inline] +fn g_sample(g: &Gray, x: f32, y: f32) -> f32 { + let (x, y) = (if x.is_finite() { x } else { 0.0 }, if y.is_finite() { y } else { 0.0 }); + let (x0, y0) = (x.floor(), y.floor()); + let (fx, fy) = (x - x0, y - y0); + let (x0, y0) = (x0 as i64, y0 as i64); + let top = g_at(g, x0, y0) + (g_at(g, x0 + 1, y0) - g_at(g, x0, y0)) * fx; + let bot = g_at(g, x0, y0 + 1) + (g_at(g, x0 + 1, y0 + 1) - g_at(g, x0, y0 + 1)) * fx; + top + (bot - top) * fy +} + +/// Perceptual-ish luma of a premultiplied linear pixel (square root lifts the shadows so their +/// edges carry gradient; HDR highlights are capped). +#[inline] +fn luma(p: [f32; 4]) -> f32 { + let l = 0.2126 * p[0] + 0.7152 * p[1] + 0.0722 * p[2]; + if l.is_finite() { l.clamp(0.0, 16.0).sqrt() } else { 0.0 } +} + +/// Luma of `img` box-reduced by `s` (≥ 1) in each direction. +fn work_luma(img: &Image, s: usize) -> Gray { + let s = s.max(1); + let (w, h) = (img.w.div_ceil(s).max(1), img.h.div_ceil(s).max(1)); + let mut px = vec![0.0f32; w * h]; + px.par_chunks_mut(w).enumerate().for_each(|(y, row)| { + for (x, o) in row.iter_mut().enumerate() { + let (mut sum, mut n) = (0.0f32, 0u32); + for yy in y * s..((y + 1) * s).min(img.h) { + for xx in x * s..((x + 1) * s).min(img.w) { + let i = (yy * img.w + xx) * 4; + if let Some(p) = img.px.get(i..i + 4) { + sum += luma([p[0], p[1], p[2], p[3]]); + n += 1; + } + } + } + *o = if n > 0 { sum / n as f32 } else { 0.0 }; + } + }); + Gray { w, h, px } +} + +/// Binomial pyramid, finest first. +fn pyramid(g: Gray) -> Vec { + let mut v = vec![g]; + while v.len() < MAX_LEVELS { + let Some(l) = v.last() else { break }; + if l.w < 2 * MIN_LEVEL || l.h < 2 * MIN_LEVEL { + break; + } + let n = l.down(); + v.push(n); + } + v +} + +/// Sum of `src` over a (2r+1)² window at every pixel (truncated at the borders). +fn box_sum(w: usize, h: usize, src: &[[f32; N]], r: usize) -> Vec<[f32; N]> { + if w == 0 || h == 0 || src.len() != w * h { + return vec![[0.0; N]; src.len()]; + } + // horizontal pass: prefix sums per row (f64, so differences of large sums stay exact) + let mut tmp = vec![[0.0f64; N]; w * h]; + tmp.par_chunks_mut(w).zip(src.par_chunks(w)).for_each(|(o, s)| { + let mut pre = vec![[0.0f64; N]; w + 1]; + for x in 0..w { + for k in 0..N { + pre[x + 1][k] = pre[x][k] + s[x][k] as f64; + } + } + for (x, ox) in o.iter_mut().enumerate() { + let (lo, hi) = (x.saturating_sub(r), (x + r + 1).min(w)); + for k in 0..N { + ox[k] = pre[hi][k] - pre[lo][k]; + } + } + }); + // vertical pass: column prefix sums, then one output row per task + let mut pre = vec![[0.0f64; N]; w * (h + 1)]; + for y in 0..h { + for x in 0..w { + for k in 0..N { + pre[(y + 1) * w + x][k] = pre[y * w + x][k] + tmp[y * w + x][k]; + } + } + } + let mut out = vec![[0.0f32; N]; w * h]; + out.par_chunks_mut(w).enumerate().for_each(|(y, row)| { + let (lo, hi) = (y.saturating_sub(r), (y + r + 1).min(h)); + for (x, o) in row.iter_mut().enumerate() { + for k in 0..N { + o[k] = (pre[hi * w + x][k] - pre[lo * w + x][k]) as f32; + } + } + }); + out +} + +/// Gauss–Newton refinement of `flow` (mapping `a` onto `b`: `a(x) ≈ b(x + u)`) at one level. +fn refine(a: &Gray, b: &Gray, flow: &mut Flow) { + let (w, h) = (a.w, a.h); + if w == 0 || h == 0 || a.px.len() != w * h || flow.uv.len() != w * h || (flow.w, flow.h) != (w, h) { + return; + } + let grad: Vec<[f32; 2]> = (0..w * h) + .into_par_iter() + .map(|i| { + let (x, y) = ((i % w) as i64, (i / w) as i64); + [(g_at(a, x + 1, y) - g_at(a, x - 1, y)) * 0.5, (g_at(a, x, y + 1) - g_at(a, x, y - 1)) * 0.5] + }) + .collect(); + let products: Vec<[f32; 3]> = grad.iter().map(|g| [g[0] * g[0], g[0] * g[1], g[1] * g[1]]).collect(); + let tensor = box_sum(w, h, &products, WIN); + for _ in 0..ITERS { + let mismatch: Vec<[f32; 2]> = (0..w * h) + .into_par_iter() + .map(|i| { + let (x, y) = ((i % w) as f32, (i / w) as f32); + let u = flow.uv[i]; + let e = g_sample(b, x + u[0], y + u[1]) - a.px[i]; + [grad[i][0] * e, grad[i][1] * e] + }) + .collect(); + let bsum = box_sum(w, h, &mismatch, WIN); + flow.uv.par_iter_mut().zip(tensor.par_iter().zip(bsum.par_iter())).for_each(|(f, (g, bv))| { + let (gxx, gxy, gyy) = (g[0] + DAMP, g[1], g[2] + DAMP); + let det = gxx * gyy - gxy * gxy; + if det > 1e-12 { + let dx = -(gyy * bv[0] - gxy * bv[1]) / det; + let dy = -(gxx * bv[1] - gxy * bv[0]) / det; + if dx.is_finite() && dy.is_finite() { + f[0] += dx.clamp(-MAX_STEP, MAX_STEP); + f[1] += dy.clamp(-MAX_STEP, MAX_STEP); + } + } + }); + } +} + +/// Where standing still explains the window at least as well as the estimated motion, the +/// motion is dropped: brightness changes (flicker, lights switching, noise) on static content +/// otherwise read as motion and would drag static detail around. +fn prefer_still(a: &Gray, b: &Gray, flow: &mut Flow) { + let (w, h) = (a.w, a.h); + if w == 0 || h == 0 || a.px.len() != w * h || b.px.len() != w * h || flow.uv.len() != w * h { + return; + } + let errs: Vec<[f32; 2]> = (0..w * h) + .into_par_iter() + .map(|i| { + let (x, y) = ((i % w) as f32, (i / w) as f32); + let u = flow.uv[i]; + let moved = g_sample(b, x + u[0], y + u[1]) - a.px[i]; + let still = b.px[i] - a.px[i]; + [moved * moved, still * still] + }) + .collect(); + let sums = box_sum(w, h, &errs, WIN); + let slack = ((2 * WIN + 1) * (2 * WIN + 1)) as f32 * NOISE * NOISE * 0.25; + flow.uv.par_iter_mut().zip(sums.par_iter()).for_each(|(f, e)| { + if e[1] <= e[0] + slack { + *f = [0.0; 2]; + } + }); +} + +/// 3×3 median of each flow component (removes isolated outliers, keeps motion edges). +fn median3(f: &Flow) -> Flow { + let (w, h) = (f.w, f.h); + let uv = (0..w * h) + .into_par_iter() + .map(|i| { + let (x, y) = ((i % w) as i64, (i / w) as i64); + let mut xs = [0.0f32; 9]; + let mut ys = [0.0f32; 9]; + let mut k = 0; + for dy in -1..=1 { + for dx in -1..=1 { + let v = f.at(x + dx, y + dy); + xs[k] = v[0]; + ys[k] = v[1]; + k += 1; + } + } + xs.sort_by(f32::total_cmp); + ys.sort_by(f32::total_cmp); + [xs[4], ys[4]] + }) + .collect(); + Flow { w, h, uv } +} + +/// `f` on a grid twice as fine (`w`×`h`), its vectors doubled. +fn upsample(f: &Flow, w: usize, h: usize) -> Flow { + let uv = (0..w * h) + .into_par_iter() + .map(|i| { + let v = f.sample((i % w) as f32 * 0.5, (i / w) as f32 * 0.5); + [v[0] * 2.0, v[1] * 2.0] + }) + .collect(); + Flow { w, h, uv } +} + +/// Coarse-to-fine dense flow from `pa[0]` to `pb[0]` (pyramids of equal-sized images). +fn dense_flow(pa: &[Gray], pb: &[Gray]) -> Option { + let levels = pa.len().min(pb.len()); + let mut flow: Option = None; + for l in (0..levels).rev() { + let (a, b) = (pa.get(l)?, pb.get(l)?); + if (a.w, a.h) != (b.w, b.h) { + return None; + } + let mut f = match &flow { + Some(c) => upsample(c, a.w, a.h), + None => Flow::zero(a.w, a.h), + }; + refine(a, b, &mut f); + prefer_still(a, b, &mut f); + flow = Some(median3(&f)); + } + flow +} + +/// The flow (in the 0 → 1 direction) of the surface seen at each pixel at time `t`, by forward +/// splatting both flows (see the module docs). +fn flow_at(a: &Gray, b: &Gray, f01: &Flow, f10: &Flow, t: f32) -> Flow { + let (w, h) = (a.w, a.h); + let n = w * h; + if n == 0 || [b.px.len(), f01.uv.len(), f10.uv.len(), a.px.len()].iter().any(|&l| l != n) { + return Flow::zero(w, h); + } + let mut best = vec![f32::INFINITY; n]; + let mut out = vec![[0.0f32; 2]; n]; + // frame 0 moves forward by t·u01, frame 1 backward by (1−t)·u10 (= forward flow −u10) + for (src, dst, f, back, tt, sign) in [(a, b, f01, f10, t, 1.0f32), (b, a, f10, f01, 1.0 - t, -1.0f32)] { + for y in 0..h { + for x in 0..w { + let i = y * w + x; + let u = f.uv[i]; + let (qx, qy) = (x as f32 + u[0], y as f32 + u[1]); + let photo = (src.px[i] - g_sample(dst, qx, qy)).abs(); + let r = back.sample(qx, qy); + let fb = ((u[0] + r[0]).powi(2) + (u[1] + r[1]).powi(2)).sqrt().min(100.0); + let score = photo + FB_WEIGHT * fb; + if !score.is_finite() { + continue; + } + let (tx, ty) = (x as f32 + tt * u[0], y as f32 + tt * u[1]); + if !(tx > -1.0 && ty > -1.0 && tx < w as f32 && ty < h as f32) { + continue; + } + let (x0, y0) = (tx.floor() as i64, ty.floor() as i64); + for (sx, sy) in [(x0, y0), (x0 + 1, y0), (x0, y0 + 1), (x0 + 1, y0 + 1)] { + if sx < 0 || sy < 0 || sx >= w as i64 || sy >= h as i64 { + continue; + } + let j = sy as usize * w + sx as usize; + if score < best[j] { + best[j] = score; + out[j] = [sign * u[0], sign * u[1]]; + } + } + } + } + } + // fill holes from their filled neighbours + let mut valid: Vec = best.iter().map(|s| s.is_finite()).collect(); + for _ in 0..FILL_PASSES { + let (prev, prev_valid) = (out.clone(), valid.clone()); + let mut changed = false; + for y in 0..h { + for x in 0..w { + let i = y * w + x; + if prev_valid[i] { + continue; + } + let (mut sum, mut k) = ([0.0f32; 2], 0u32); + for (dx, dy) in [(-1i64, -1i64), (0, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (0, 1), (1, 1)] { + let (nx, ny) = (x as i64 + dx, y as i64 + dy); + if nx < 0 || ny < 0 || nx >= w as i64 || ny >= h as i64 { + continue; + } + let j = ny as usize * w + nx as usize; + if prev_valid[j] { + sum = [sum[0] + prev[j][0], sum[1] + prev[j][1]]; + k += 1; + } + } + if k > 0 { + out[i] = [sum[0] / k as f32, sum[1] / k as f32]; + valid[i] = true; + changed = true; + } + } + } + if !changed { + break; + } + } + Flow { w, h, uv: out } +} + +#[inline] +fn agree(d: f32) -> f32 { + let q = d / AGREE_PX; + 1.0 / (1.0 + q * q) +} + +/// The frame between `a` (time 0) and `b` (time 1) at `t` (0..1), motion compensated. Frames of +/// different sizes (or empty ones) fall back to the plain cross-fade. +pub fn interpolate(a: &Image, b: &Image, t: f32) -> Image { + let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 }; + let n = a.w.saturating_mul(a.h).saturating_mul(4); + if (a.w, a.h) != (b.w, b.h) || a.w == 0 || a.h == 0 || a.px.len() != n || b.px.len() != n { + return a.clone().lerp(b, t); + } + if t <= 0.0 { + return a.clone(); + } + if t >= 1.0 { + return b.clone(); + } + let s = a.w.div_ceil(WORK_W).max(1); + let (pa, pb) = (pyramid(work_luma(a, s)), pyramid(work_luma(b, s))); + let (Some(ga), Some(gb), Some(f01), Some(f10)) = (pa.first(), pb.first(), dense_flow(&pa, &pb), dense_flow(&pb, &pa)) else { + return a.clone().lerp(b, t); + }; + let ft = flow_at(ga, gb, &f01, &f10, t); + let sf = s as f32; + let to_work = |v: f32| v / sf - 0.5; + let mut out = Image::new(a.w, a.h); + out.px.par_chunks_mut(a.w * 4).enumerate().for_each(|(y, row)| { + for (x, o) in row.as_chunks_mut::<4>().0.iter_mut().enumerate() { + let (px, py) = (x as f32 + 0.5, y as f32 + 0.5); + let u = ft.sample(to_work(px), to_work(py)); + let (ux, uy) = (u[0] * sf, u[1] * sf); + let (x0, y0) = (px - t * ux, py - t * uy); + let (x1, y1) = (px + (1.0 - t) * ux, py + (1.0 - t) * uy); + // does the fetched pixel itself move along this trajectory? + let ua = f01.sample(to_work(x0), to_work(y0)); + let ub = f10.sample(to_work(x1), to_work(y1)); + let ka = agree(((ua[0] - u[0]).powi(2) + (ua[1] - u[1]).powi(2)).sqrt()); + let kb = agree(((ub[0] + u[0]).powi(2) + (ub[1] + u[1]).powi(2)).sqrt()); + let (wa, wb) = ((1.0 - t) * ka, t * kb); + let ca = a.sample_bilinear_clamped(x0, y0); + let cb = b.sample_bilinear_clamped(x1, y1); + let i = (y * a.w + x) * 4; + let (Some(pa), Some(pb)) = (a.px.get(i..i + 4), b.px.get(i..i + 4)) else { continue }; + let sum = wa + wb; + // a pixel that looks the same in both frames needs no motion compensation (static + // content, flat interiors): the cross-fade is right there and immune to flow errors + let changed = (luma([pa[0], pa[1], pa[2], 0.0]) - luma([pb[0], pb[1], pb[2], 0.0])).abs().max((pa[3] - pb[3]).abs()); + let changed = if changed.is_finite() { ((changed - STILL_LUMA) / STILL_LUMA).clamp(0.0, 1.0) } else { 0.0 }; + let conf = ka.max(kb) * changed; + for k in 0..4 { + let warped = if sum > 1e-6 { (ca[k] * wa + cb[k] * wb) / sum } else { ca[k] * (1.0 - t) + cb[k] * t }; + let plain = pa[k] + (pb[k] - pa[k]) * t; + o[k] = plain + (warped - plain) * conf; + } + } + }); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A smooth, textured, premultiplied opaque picture sampled with the content offset by `(dx, dy)`. + fn texture(w: usize, h: usize, dx: f32, dy: f32) -> Image { + let mut img = Image::new(w, h); + for y in 0..h { + for x in 0..w { + let (u, v) = (x as f32 + 0.5 - dx, y as f32 + 0.5 - dy); + let l = 0.35 + 0.15 * (u * 0.21).sin() * (v * 0.17).cos() + 0.1 * ((u + v) * 0.11).sin() + 0.05 * (u * 0.53 - v * 0.31).cos(); + let i = (y * w + x) * 4; + img.px[i..i + 4].copy_from_slice(&[l, l * 0.9, l * 0.8, 1.0]); + } + } + img + } + + /// A flat grey background with a white square of side `side` whose left/top edge is at `(x0, y0)`. + fn square(w: usize, h: usize, x0: usize, y0: usize, side: usize) -> Image { + let mut img = Image::filled(w, h, [0.1, 0.1, 0.1, 1.0]); + for y in y0..(y0 + side).min(h) { + for x in x0..(x0 + side).min(w) { + let i = (y * w + x) * 4; + img.px[i..i + 4].copy_from_slice(&[1.0, 1.0, 1.0, 1.0]); + } + } + img + } + + /// Mean absolute difference over the pixels at least `margin` from every edge. + fn interior_diff(a: &Image, b: &Image, margin: usize) -> f32 { + let (mut s, mut n) = (0.0f32, 0usize); + for y in margin..a.h - margin { + for x in margin..a.w - margin { + let (p, q) = (a.get(x, y), b.get(x, y)); + s += (0..3).map(|k| (p[k] - q[k]).abs()).sum::(); + n += 3; + } + } + s / n as f32 + } + + #[test] + fn uniform_motion_lands_half_way() { + let (a, b) = (texture(160, 96, 0.0, 0.0), texture(160, 96, 6.0, -3.0)); + let want = texture(160, 96, 3.0, -1.5); + let flow = interpolate(&a, &b, 0.5); + let blend = a.clone().lerp(&b, 0.5); + let (e_flow, e_blend) = (interior_diff(&flow, &want, 12), interior_diff(&blend, &want, 12)); + assert!(e_flow < 0.006, "optical flow off the half-way picture: {e_flow}"); + assert!(e_flow * 5.0 < e_blend, "optical flow {e_flow} should beat frame blending {e_blend}"); + } + + #[test] + fn quarter_way_follows_the_time() { + let (a, b) = (texture(160, 96, 0.0, 0.0), texture(160, 96, 8.0, 4.0)); + let want = texture(160, 96, 2.0, 1.0); + let e = interior_diff(&interpolate(&a, &b, 0.25), &want, 12); + assert!(e < 0.006, "{e}"); + } + + #[test] + fn a_moving_square_appears_once_at_its_interpolated_place() { + // a 24 px square moving 10 px right: Frame Blending shows two half-bright copies + let (a, b) = (square(128, 96, 40, 36, 24), square(128, 96, 50, 36, 24)); + let want = square(128, 96, 45, 36, 24); + let flow = interpolate(&a, &b, 0.5); + let blend = a.clone().lerp(&b, 0.5); + // the leading and trailing bands are where blending leaves ghosts + for x in [42usize, 71] { + let (f, bl, w) = (flow.get(x, 48)[0], blend.get(x, 48)[0], want.get(x, 48)[0]); + assert!((f - w).abs() < 0.1, "x={x}: flow {f}, want {w} (blend {bl})"); + assert!((bl - w).abs() > 0.3, "x={x}: the blend should ghost here"); + } + assert!(flow.get(57, 48)[0] > 0.95, "the square's centre stays solid"); + let (e_flow, e_blend) = (interior_diff(&flow, &want, 4), interior_diff(&blend, &want, 4)); + assert!(e_flow * 4.0 < e_blend, "flow {e_flow} vs blend {e_blend}"); + } + + #[test] + fn flicker_on_static_content_is_not_dragged_around() { + // static rows of lit windows; between the frames some switch on and some grow a pixel: + // brightness change, not motion, so the result is the plain cross-fade + let (w, h) = (160usize, 96usize); + let mut a = Image::filled(w, h, [0.004, 0.004, 0.006, 1.0]); + for row in 0..8 { + for col in 0..10 { + let (x0, y0) = (6 + col * 15, 6 + row * 11); + for x in x0..x0 + 8 { + for y in y0..y0 + 2 { + let i = (y * w + x) * 4; + a.px[i..i + 4].copy_from_slice(&[0.6, 0.45, 0.1, 1.0]); + } + } + } + } + let mut b = a.clone(); + for (k, i) in (0..w * h).step_by(97).enumerate() { + let lit = if k % 2 == 0 { [0.6, 0.45, 0.1, 1.0] } else { [0.004, 0.004, 0.006, 1.0] }; + b.px[i * 4..i * 4 + 4].copy_from_slice(&lit); + } + let flow = interpolate(&a, &b, 0.5); + let blend = a.clone().lerp(&b, 0.5); + let e = interior_diff(&flow, &blend, 0); + assert!(e < 1e-3, "static detail moved: {e}"); + } + + #[test] + fn identical_frames_are_returned_unchanged() { + let a = texture(64, 48, 0.0, 0.0); + let out = interpolate(&a, &a.clone(), 0.37); + assert!(interior_diff(&out, &a, 0) < 1e-5); + } + + #[test] + fn large_frames_estimate_flow_at_the_working_size() { + // wider than WORK_W: the flow is estimated on a reduced copy and scaled back up + let (a, b) = (texture(1400, 120, 0.0, 0.0), texture(1400, 120, 12.0, 0.0)); + let want = texture(1400, 120, 6.0, 0.0); + let e = interior_diff(&interpolate(&a, &b, 0.5), &want, 24); + assert!(e < 0.01, "{e}"); + } + + #[test] + fn hostile_inputs_do_not_panic() { + let a = texture(32, 24, 0.0, 0.0); + // size mismatch and empty frames fall back to the cross-fade + let small = texture(16, 24, 0.0, 0.0); + assert_eq!(interpolate(&a, &small, 0.5), a.clone().lerp(&small, 0.5)); + let empty = Image::new(0, 0); + assert_eq!(interpolate(&empty, &empty, 0.5).px.len(), 0); + // a pixel buffer that does not match its size + let broken = Image { w: 32, h: 24, px: vec![0.0; 7] }; + assert_eq!(interpolate(&broken, &a, 0.5).w, 32); + // end points and non-finite weights + assert_eq!(interpolate(&a, &small.clone(), f32::NAN).w, 32); + let b = texture(32, 24, 2.0, 1.0); + assert_eq!(interpolate(&a, &b, 0.0), a); + assert_eq!(interpolate(&a, &b, 7.0), b); + assert_eq!(interpolate(&a, &b, f32::NAN), a); + // NaN / infinite pixels and tiny frames + let mut nan = b.clone(); + nan.px[40] = f32::NAN; + nan.px[41] = f32::INFINITY; + nan.px[45] = -f32::INFINITY; + assert_eq!(interpolate(&a, &nan, 0.5).px.len(), a.px.len()); + for (w, h) in [(1, 1), (2, 1), (1, 3), (3, 2)] { + let (p, q) = (texture(w, h, 0.0, 0.0), texture(w, h, 1.0, 0.0)); + assert_eq!(interpolate(&p, &q, 0.5).px.len(), w * h * 4); + } + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 1d6ff3c4..22d6629f 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -15,6 +15,7 @@ pub mod blend; pub mod color_match; pub mod colorman; pub mod effects; +pub mod flow; pub mod gpufx; pub mod graphic_clip; pub mod graphics; @@ -535,8 +536,8 @@ pub(crate) fn video_source_time(item: &TrackItem, t: Tick, media_rate: filmcraft media_rate.tick_of(media_rate.frame_at(ft)).clamp(item.source_in.min(ft), ft) } -/// The linear image of a media clip's `frame`, colour managed, and blended with the next frame when -/// the clip's speed asks for it. +/// The linear image of a media clip's `frame`, colour managed, and blended (or, with Optical Flow, +/// motion interpolated) with the next frame when the clip's speed asks for it. #[allow(clippy::too_many_arguments)] fn media_image(project: &Project, seq: &Sequence, item: &TrackItem, t: Tick, src: &SharedSource, frame: &VideoFrame, n: usize, want: f32) -> Image { let img = colorman::decode(project, item.item, frame, n, &seq.settings.color); @@ -544,7 +545,7 @@ fn media_image(project: &Project, seq: &Sequence, item: &TrackItem, t: Tick, src Some((next_time, wgt)) => match src.video_frame(FrameRequest { time: next_time, scale: want }) { Ok(f2) if f2.width == frame.width && f2.height == frame.height => { let b = colorman::decode(project, item.item, &f2, n, &seq.settings.color); - img.lerp(&b, wgt) + if item.time_interpolation == filmcraft_project::TimeInterpolation::OpticalFlow { flow::interpolate(&img, &b, wgt) } else { img.lerp(&b, wgt) } } _ => img, }, @@ -618,9 +619,8 @@ pub fn render_clip( /// Frame Blending / Optical Flow on a speed-changed clip: the later source frame to mix in and /// its weight (0..1) at timeline `t`, or None when the exact media time falls on a source frame -/// (or the clip plays at 100 %, is frame-held, or uses Frame Sampling). -/// -/// TODO(optical flow): motion-compensated interpolation; Optical Flow renders as Frame Blending. +/// (or the clip plays at 100 %, is frame-held, or uses Frame Sampling). Frame Blending cross-fades +/// the two frames; Optical Flow synthesises the in-between frame from their motion ([`flow`]). pub fn interpolation_blend(item: &TrackItem, t: Tick, src_rate: filmcraft_time::FrameRate) -> Option<(Tick, f32)> { if item.time_interpolation == filmcraft_project::TimeInterpolation::FrameSampling || item.frame_hold.is_some() { return None; diff --git a/crates/render/src/track.rs b/crates/render/src/track.rs index 06ecfa76..aa07c45c 100644 --- a/crates/render/src/track.rs +++ b/crates/render/src/track.rs @@ -71,7 +71,7 @@ impl Gray { top + (bot - top) * fy } /// 5-tap binomial blur then 2× decimation. - fn down(&self) -> Gray { + pub(crate) fn down(&self) -> Gray { let k = [1.0, 4.0, 6.0, 4.0, 1.0].map(|v: f32| v / 16.0); let (w, h) = (self.w, self.h); let mut tmp = vec![0.0f32; w * h]; diff --git a/docs/gaps.md b/docs/gaps.md index 3532ae7b..883ae8eb 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -215,10 +215,12 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** ## G18. Effects depth: approximations and missing behaviour -- **Missing:** Warp Stabilizer is 2-D (Premiere's is 3-D subspace), Morph Cut approximate, optical - flow renders as frame blending, Transform shutter angle adds no motion blur (#415), Ultra Key - Setting scales only two factors (#458), Lumetri HDR-aware maths, multicam paging beyond 16 - angles, redesigned 26.0 mask tools (rounded corners, constrained lines). +- **Missing:** Warp Stabilizer is 2-D (Premiere's is 3-D subspace), Morph Cut approximate, Lumetri + HDR-aware maths, multicam paging beyond 16 angles, redesigned 26.0 mask tools (rounded corners, + constrained lines). +- **Have:** Optical Flow time interpolation synthesises motion-compensated in-between frames (dense + coarse-to-fine Lucas–Kanade, CPU; #780); Transform shutter angle motion blur (#415); Ultra Key + Setting applied as a parameter preset (#458). - **Estimate:** 60–110 h. ## G19. Speech to text off in release builds diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 29b2f706..335bef34 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -104,7 +104,7 @@ agent-hours to close the area to Premiere, sequential. | Area | Weight | Breadth | Ready | Hours | Evidence | Detail | |---|---|---|---|---|---|---| | Editing, timeline, trimming, multicam | 20% | ~90% | ~70% | 80–140 | Edit algebra, trim modes, Trim Monitor, multicam with audio sync, Premiere default keyboard (143 commands), Source playback and range dragging (10-08). Open: ripple-delete of a gap by column (#668), Backspace on gaps (#648), clip stretching (#653), deselect on empty space (#683), new tracks by dragging media (#483), Escape cancelling drags (#580), time remapping (#510), effects on a whole track (#623) | [ui-parity.md](ui-parity.md) | -| Effects and transitions | 12% | 100% (measured) | ~60% | 80–140 | Every effect exists; Warp Stabilizer is 2-D, Morph Cut and Auto Reframe approximate, optical flow renders as frame blending. 34 effect ids run on the GPU against ~82 of Premiere's ~92 marked Accelerated. No Object Mask (26.0, AI). Transform shutter angle adds no motion blur (#415); Ultra Key setting (#458) | [hardware-parity.md](hardware-parity.md) | +| Effects and transitions | 12% | 100% (measured) | ~60% | 80–140 | Every effect exists; Warp Stabilizer is 2-D, Morph Cut and Auto Reframe approximate; Optical Flow time interpolation is motion compensated on the CPU (#780). 34 effect ids run on the GPU against ~82 of Premiere's ~92 marked Accelerated. No Object Mask (26.0, AI). | [hardware-parity.md](hardware-parity.md) | | Media import and codecs | 12% | ~75% | ~60% | 150–250 | Own bit-exact decoders for H.264, HEVC, VP9, AV1, ProRes, DNx, APV, MPEG-2, AAC, AC-3, Opus. But H.264 only 8-bit 4:2:0 and HEVC no 4:2:2 in software (most 10-bit cameras, #626); no camera RAW (R3D, ARRIRAW, BRAW, Sony RAW, Canon RAW, ProRes RAW, #345, #383); no E-AC-3 (#647), AVI (#598), WMV, DV, XAVC / P2 metadata; Sony start timecode (#460); WebM black (#432) | [codec-parity.md](codec-parity.md), [file-format-parity.md](file-format-parity.md) | | Audio | 10% | ~88% | ~65% | 50–90 | Mixer graph, automation, all 53 effects, Essential Sound, 5.1, voice-over, Remix, multi-stream container audio (#361). Device and playback reports: crackle / cut-outs (#336, #410, #602, #605, #617); Enhance Speech is DSP, not a model; ALSA fallback fixed (#254) | | | Panels and UI fidelity | 10% | ~88% | ~60% | 120–200 | All main panels. Missing: on-screen transform handles in the Program monitor (#639), Effect Controls keyframe zoom / divider / row lines (#640–#645), docking rearrangement (#493), multi-monitor windows (#466), filmstrip thumbnails (#614), marquee in Project panel (#578) | [ui-parity.md](ui-parity.md) | From 022e645a91a25f98894a6da1555bccc18075b430 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 04:50:20 +0700 Subject: [PATCH 130/144] fix(playback): keep audio during forward shuttle speeds (#771) --- crates/ui-egui/src/lib.rs | 53 ++++++++++++++++++++++++++++++++------- 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 38ee823c..5b6208ed 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -954,19 +954,19 @@ impl FilmcraftApp { } fn start_audio(&mut self) { - let speed = self.playback.speed; - if (speed - 1.0).abs() > 1e-9 { - if let Some(a) = self.audio.as_mut() { - a.stop(); - } - return; - } let Some(seq_id) = self.session.state.active_sequence else { return }; let start_tick = self.session.playhead(); let Some(a) = self.audio.as_mut() else { return }; let sr = a.sample_rate(); - let cursor = start_tick.to_units_floor(sr as i64); - let mix = playback_mix(&self.session, seq_id, sr); + // Mix at a lower effective rate for forward shuttle playback, then hand the + // samples to the device at its real rate. This keeps sound on the same + // timeline as the accelerated picture (with the expected pitch shift). + let Some(mix_rate) = shuttle_mix_rate(sr, self.playback.speed) else { + a.stop(); + return; + }; + let cursor = start_tick.to_units_floor(i64::from(mix_rate)); + let mix = playback_mix(&self.session, seq_id, mix_rate); // the old stream stops before the new mixer resets the underrun counters a.stop(); // Desktop: mix ahead on a thread so the device callback never waits on decoding. @@ -1989,6 +1989,20 @@ impl eframe::App for FilmcraftApp { } } +/// Effective sample rate for shuttle playback. The audio device still runs at `device_rate`, +/// so at 2x its output samples represent twice the timeline duration. Reverse and +/// extreme shuttle speeds remain silent rather than asking the mixer for invalid rates. +fn shuttle_mix_rate(device_rate: u32, speed: f64) -> Option { + if device_rate == 0 || !speed.is_finite() || !(0.5..=4.0).contains(&speed) { + return None; + } + let rate = (f64::from(device_rate) / speed).round(); + if !(1.0..=f64::from(u32::MAX)).contains(&rate) { + return None; + } + Some(rate as u32) +} + /// The program mix that playback plays: `mix(device_frame, interleaved, channels)` renders the /// active sequence at device rate `sr` from `device_frame`, with the live project snapshot (edits /// made while playing are heard), Output Mapping, the 5.1 mixdown and voice-over cues. @@ -2185,6 +2199,27 @@ mod audio_recovery_tests { } } + #[test] + fn forward_shuttle_playback_keeps_audio_enabled() { + assert_eq!(shuttle_mix_rate(48_000, 1.0), Some(48_000)); + assert_eq!(shuttle_mix_rate(48_000, 2.0), Some(24_000)); + assert_eq!(shuttle_mix_rate(48_000, 4.0), Some(12_000)); + assert_eq!(shuttle_mix_rate(48_000, -1.0), None); + assert_eq!(shuttle_mix_rate(0, 2.0), None); + assert_eq!(shuttle_mix_rate(48_000, f64::NAN), None); + + let mut session = Session::default(); + session.execute("file.newSequence", json!({"width":16,"height":16})).unwrap(); + let mut app = FilmcraftApp::new(session); + let starts = Arc::new(AtomicUsize::new(0)); + app.audio = Some(Box::new(Device { ready: true, starts: starts.clone(), reading: Some(0) })); + app.play(2.0); + app.end_preroll(0.0); + assert_eq!(starts.load(Ordering::Relaxed), 1, "2x playback must start audio output"); + assert!(app.playback.audio_clock, "the device is still the master clock"); + app.stop(); + } + #[test] fn changing_hardware_recovers_wall_clock_playback_without_rewinding() { let mut session = Session::default(); From c673ee85b99dac66db2d54f0b5bf016e69671feb Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 23:51:41 +0200 Subject: [PATCH 131/144] #779: export JPEG, Targa and DPX image sequences Problem: Premiere offers JPEG, Targa and DPX as image-sequence export formats; FilmCraft only wrote PNG, TIFF and BMP sequences (docs/gaps.md G13). Fix: three new export formats (ids jpg, tga, dpx), numbered like the other sequences, with presets, the export UI (JPEG quality slider, Targa alpha) and es/ja/zh-cn strings. JPEG uses the image crate at the export quality; Targa (TGA 2.0, uncompressed 24/32-bit) and DPX (SMPTE ST 268, 10-bit RGB method A, BT.709) are written by a small hostile-input-safe writer in crates/export/src/raster.rs. Format::is_image_sequence replaces the hand-kept format lists. OpenEXR stays open. Co-Authored-By: Claude Opus 5.5 --- crates/automation/src/long_job.rs | 4 +- crates/engine/src/commands.rs | 2 +- crates/engine/src/export_tests.rs | 5 +- crates/engine/src/export_tools.rs | 8 +- crates/export/src/lib.rs | 67 +++++++-- crates/export/src/presets.rs | 8 + crates/export/src/raster.rs | 179 +++++++++++++++++++++++ crates/export/src/settings.rs | 18 ++- crates/export/src/settings_tests.rs | 53 ++++++- crates/ui-egui/src/i18n/es.tsv | 9 ++ crates/ui-egui/src/i18n/ja.tsv | 9 ++ crates/ui-egui/src/i18n/zh-cn.tsv | 9 ++ crates/ui-egui/src/panels/export_mode.rs | 2 +- docs/architecture.md | 3 +- docs/file-format-parity.md | 6 +- docs/gaps.md | 3 +- 16 files changed, 357 insertions(+), 28 deletions(-) create mode 100644 crates/export/src/raster.rs diff --git a/crates/automation/src/long_job.rs b/crates/automation/src/long_job.rs index 8d4035ab..62549b90 100644 --- a/crates/automation/src/long_job.rs +++ b/crates/automation/src/long_job.rs @@ -108,7 +108,7 @@ impl FilmcraftMcp { /// Delete what an interrupted export wrote, and nothing else: the output file itself, the numbered /// stills of an image sequence (`<3+ digits>.`, only when the output is a -/// PNG/TIFF/BMP sequence) and the caption sidecar +/// PNG/TIFF/BMP/JPEG/Targa/DPX sequence) and the caption sidecar /// (`.srt` / `.vtt`), each only if it was written since `since`. Other files that /// merely share the name's stem (`.aep`, `2.psd`, `1.mp4` beside a movie, …) are /// never touched, even when they were saved during the export. @@ -152,7 +152,7 @@ fn is_export_output(name: &str, file: &str, stem: &str, ext: &str) -> bool { /// Whether an export to a file with extension `ext` writes an image sequence (one still per frame). fn is_sequence_extension(ext: &str) -> bool { use filmcraft_engine::export::Format; - Format::from_name(ext).is_some_and(|f| matches!(f, Format::PngSequence | Format::TiffSequence | Format::BmpSequence)) + Format::from_name(ext).is_some_and(Format::is_image_sequence) } #[cfg(test)] diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index d272c41f..c0494d1d 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1268,7 +1268,7 @@ fn build() -> Vec { "Media…", ["File", "Export"], None, - r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass|crf"?,"crf":0..51=23,"hardwareEncoding":"off|auto"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"alpha":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq|4444|4444xq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#, + r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|jpg|tga|dpx|gif|wav|aiff"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass|crf"?,"crf":0..51=23,"hardwareEncoding":"off|auto"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"alpha":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq|4444|4444xq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#, has_seq, crate::export_tools::export_media ), diff --git a/crates/engine/src/export_tests.rs b/crates/engine/src/export_tests.rs index e0d58794..6a8c5d71 100644 --- a/crates/engine/src/export_tests.rs +++ b/crates/engine/src/export_tests.rs @@ -137,7 +137,7 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() { Some(a) => (a[0].as_u64().unwrap(), a[1].as_u64().unwrap()), None => (1920, 1080), }; - if matches!(fmt.as_str(), "png" | "tiff" | "bmp") { + if matches!(fmt.as_str(), "png" | "tiff" | "bmp" | "jpg" | "tga" | "dpx") { 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"); @@ -148,6 +148,9 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() { let codec = match fmt.as_str() { "png" => "png", "tiff" => "tiff", + "jpg" => "mjpeg", + "tga" => "targa", + "dpx" => "dpx", _ => "bmp", }; assert_eq!(v["codec_name"], codec, "{name}"); diff --git a/crates/engine/src/export_tools.rs b/crates/engine/src/export_tools.rs index 5c4d875b..9e37a780 100644 --- a/crates/engine/src/export_tools.rs +++ b/crates/engine/src/export_tools.rs @@ -360,7 +360,13 @@ fn file_safe(n: &str) -> String { fn with_extension(path: &str, settings: &ExportSettings) -> String { let ext = settings.extension(); match Path::new(path).extension().and_then(|e| e.to_str()) { - Some(e) if e.eq_ignore_ascii_case(ext) || (settings.format == Format::TiffSequence && e.eq_ignore_ascii_case("tiff")) => path.to_string(), + Some(e) + if e.eq_ignore_ascii_case(ext) + || (settings.format == Format::TiffSequence && e.eq_ignore_ascii_case("tiff")) + || (settings.format == Format::JpegSequence && e.eq_ignore_ascii_case("jpeg")) => + { + path.to_string() + } Some(_) if settings.is_image_sequence() || matches!(settings.format, Format::Aiff) => format!("{}.{ext}", path.rsplit_once('.').map_or(path, |x| x.0)), _ => format!("{path}.{ext}"), } diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 57f8850b..279384f2 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -5,8 +5,8 @@ //! are shared atomics so the UI (Export mode, header progress) and MCP can observe/cancel jobs. //! //! Video encoders implement [`VideoEncoder`]; codec crates register theirs with -//! [`register_encoder`] (H.264, ProRes …). Built in: Motion-JPEG (MOV), PNG / TIFF / BMP -//! sequences, GIF, WAV and AIFF. +//! [`register_encoder`] (H.264, ProRes …). Built in: Motion-JPEG (MOV), PNG / TIFF / BMP / JPEG / +//! Targa / DPX sequences, GIF, WAV and AIFF. //! //! [`ExportSettings`] carries every Export-mode setting (frame size, rate, bitrate encoding, audio //! format, multiplexer, captions, effects, metadata) as serde data; [`presets`] defines the @@ -22,6 +22,7 @@ mod pace; mod pcm; mod pipeline; pub mod presets; +mod raster; pub mod settings; pub mod still; pub use audio_out::LoudnessReport; @@ -96,6 +97,15 @@ pub enum Format { /// Numbered BMP stills. #[serde(rename = "bmp", alias = "BmpSequence")] BmpSequence, + /// Numbered JPEG stills (baseline, [`ExportSettings::quality`]). + #[serde(rename = "jpg", alias = "JpegSequence")] + JpegSequence, + /// Numbered Targa stills (uncompressed, 24-bit, or 32-bit with [`ExportSettings::alpha`]). + #[serde(rename = "tga", alias = "TgaSequence")] + TgaSequence, + /// Numbered DPX stills (SMPTE ST 268, 10-bit RGB, BT.709). + #[serde(rename = "dpx", alias = "DpxSequence")] + DpxSequence, #[serde(rename = "gif", alias = "Gif")] Gif, #[serde(rename = "wav", alias = "Wav")] @@ -126,6 +136,10 @@ impl Format { "png" | "pngsequence" => Format::PngSequence, "tif" | "tiff" | "tiffsequence" => Format::TiffSequence, "bmp" | "bmpsequence" => Format::BmpSequence, + // "jpeg" alone stays Motion-JPEG (QuickTime), as it always was + "jpg" | "jpgsequence" | "jpegsequence" => Format::JpegSequence, + "tga" | "targa" | "tgasequence" | "targasequence" => Format::TgaSequence, + "dpx" | "dpxsequence" => Format::DpxSequence, "gif" | "animatedgif" => Format::Gif, "wav" | "waveform" | "waveformaudio" => Format::Wav, "aif" | "aiff" | "aifc" => Format::Aiff, @@ -145,6 +159,9 @@ impl Format { Format::PngSequence => "png", Format::TiffSequence => "tiff", Format::BmpSequence => "bmp", + Format::JpegSequence => "jpg", + Format::TgaSequence => "tga", + Format::DpxSequence => "dpx", Format::Gif => "gif", Format::Wav => "wav", Format::Aiff => "aiff", @@ -159,6 +176,9 @@ impl Format { Format::PngSequence => "png", Format::TiffSequence => "tif", Format::BmpSequence => "bmp", + Format::JpegSequence => "jpg", + Format::TgaSequence => "tga", + Format::DpxSequence => "dpx", Format::Gif => "gif", Format::Wav => "wav", Format::Aiff => "aif", @@ -177,6 +197,9 @@ impl Format { Format::PngSequence => "PNG", Format::TiffSequence => "TIFF", Format::BmpSequence => "BMP", + Format::JpegSequence => "JPEG", + Format::TgaSequence => "Targa", + Format::DpxSequence => "DPX", Format::Gif => "Animated GIF", Format::Wav => "Waveform Audio", Format::Aiff => "AIFF", @@ -184,6 +207,10 @@ impl Format { Format::MxfOpAtom => "MXF OP-Atom", } } + /// Numbered stills, one per frame ([`image_sequence_path`]). + pub fn is_image_sequence(self) -> bool { + matches!(self, Format::PngSequence | Format::TiffSequence | Format::BmpSequence | Format::JpegSequence | Format::TgaSequence | Format::DpxSequence) + } /// An MXF container format. pub fn is_mxf(self) -> bool { matches!(self, Format::MxfOp1a | Format::MxfOpAtom) @@ -205,7 +232,7 @@ impl Format { pub fn is_mp4_with(self, mux: Multiplexer) -> bool { self == Format::Av1 || (self.is_h26x() && mux == Multiplexer::Mp4) } - pub const ALL: [Format; 15] = [ + pub const ALL: [Format; 18] = [ Format::H264, Format::Hevc, Format::Av1, @@ -216,6 +243,9 @@ impl Format { Format::PngSequence, Format::TiffSequence, Format::BmpSequence, + Format::JpegSequence, + Format::TgaSequence, + Format::DpxSequence, Format::Gif, Format::Wav, Format::Aiff, @@ -244,7 +274,7 @@ pub struct ExportSettings { /// Output scale (1.0 = sequence frame size). pub scale: f32, pub include_audio: bool, - /// Quality 0–100 for lossy codecs. + /// Quality 0–100 for lossy codecs (Motion-JPEG, JPEG sequences). pub quality: u8, /// Target video bitrate (kbps) for bitrate-driven encoders. pub bitrate_kbps: u32, @@ -272,7 +302,7 @@ pub struct ExportSettings { /// sequence's HDR space and signal it (VUI / `colr` / `mdcv` / `clli` / SEI). #[serde(default)] pub sdr: bool, - /// Keep the alpha channel in PNG and TIFF sequences and ProRes 4444 / 4444 XQ QuickTime movies + /// Keep the alpha channel in PNG, TIFF and Targa sequences and ProRes 4444 / 4444 XQ QuickTime movies /// (straight alpha, Premiere's "Include Alpha Channel"). Off, every frame is flattened over /// black (#160). Other formats ignore it. #[serde(default)] @@ -1629,11 +1659,30 @@ pub fn encode_png(rgba: Vec, w: u32, h: u32) -> Result> { } /// Encode one still of an image sequence (also Export Frame). PNG always carries the alpha -/// channel it is given; `alpha` keeps it in TIFF too, which is otherwise written as RGB. +/// channel it is given; `alpha` keeps it in TIFF and Targa too, which are otherwise written as RGB. +/// JPEG uses the default export quality ([`encode_still_with_quality`] picks another). pub fn encode_still(format: Format, rgba: Vec, w: u32, h: u32, alpha: bool) -> Result> { + encode_still_with_quality(format, rgba, w, h, alpha, ExportSettings::default().quality) +} + +/// [`encode_still`] with the JPEG quality (1–100; ignored by the lossless formats). +pub fn encode_still_with_quality(format: Format, rgba: Vec, w: u32, h: u32, alpha: bool, quality: u8) -> Result> { let enc = |e: image::ImageError| ExportError::Encode(e.to_string()); let mut out = std::io::Cursor::new(Vec::new()); match format { + Format::JpegSequence => { + let rgb = raster::rgb(&rgba, w, h)?; + image::ImageEncoder::write_image( + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality.clamp(1, 100)), + &rgb, + w, + h, + image::ExtendedColorType::Rgb8, + ) + .map_err(enc)? + } + Format::TgaSequence => return raster::tga(&rgba, w, h, alpha), + Format::DpxSequence => return raster::dpx(&rgba, w, h), Format::PngSequence => { image::ImageEncoder::write_image(image::codecs::png::PngEncoder::new(&mut out), &rgba, w, h, image::ExtendedColorType::Rgba8).map_err(enc)? } @@ -1707,7 +1756,7 @@ pub fn export(project: &Arc, seq: ItemId, settings: &ExportSettings, so } (n, frames) } - Format::PngSequence | Format::TiffSequence | Format::BmpSequence | Format::Gif => { + f if f.is_image_sequence() || f == Format::Gif => { let pipe = pipeline::Pipeline::new(project.clone(), seq, settings, false)?; let (f0, f1) = frame_span(pipe.rate, range); let count = (f1 - f0) as u64; @@ -1742,7 +1791,7 @@ pub fn export(project: &Arc, seq: ItemId, settings: &ExportSettings, so let written: Vec> = (f..end) .into_par_iter() .map(|fi| { - let data = encode_still(settings.format, pipe.frame(fi, sources).0, w, h, settings.alpha)?; + let data = encode_still_with_quality(settings.format, pipe.frame(fi, sources).0, w, h, settings.alpha, settings.quality)?; write_output(settings, &pcm::image_sequence_path(&settings.path, (fi - f0) as u64, count), data) }) .collect(); @@ -1761,7 +1810,7 @@ pub fn export(project: &Arc, seq: ItemId, settings: &ExportSettings, so } (total, count) } - Format::H264 | Format::Hevc | Format::Av1 | Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg | Format::MxfOp1a | Format::MxfOpAtom => { + _ => { // Handled by the stepped exporter above; reaching here would be a dispatch bug. return Err(ExportError::Unsupported(format!("{:?} must run as a stepped export", settings.format))); } diff --git a/crates/export/src/presets.rs b/crates/export/src/presets.rs index 4323de77..2a021d49 100644 --- a/crates/export/src/presets.rs +++ b/crates/export/src/presets.rs @@ -152,6 +152,14 @@ pub fn builtin_presets() -> Vec { ), p("TIFF Sequence", "Image Sequence", "Numbered TIFF stills (RGB, 8-bit)", ExportSettings { format: Format::TiffSequence, ..Default::default() }), p("BMP Sequence", "Image Sequence", "Numbered BMP stills (24-bit)", ExportSettings { format: Format::BmpSequence, ..Default::default() }), + p("JPEG Sequence", "Image Sequence", "Numbered JPEG stills (quality 90)", ExportSettings { format: Format::JpegSequence, ..Default::default() }), + p( + "Targa Sequence", + "Image Sequence", + "Numbered Targa stills (24-bit, uncompressed)", + ExportSettings { format: Format::TgaSequence, ..Default::default() }, + ), + p("DPX Sequence", "Image Sequence", "Numbered DPX stills (10-bit RGB)", ExportSettings { format: Format::DpxSequence, ..Default::default() }), p( "Animated GIF 640×360", "Animated GIF", diff --git a/crates/export/src/raster.rs b/crates/export/src/raster.rs new file mode 100644 index 00000000..59641319 --- /dev/null +++ b/crates/export/src/raster.rs @@ -0,0 +1,179 @@ +//! Uncompressed still formats written directly from RGBA8 frames: Targa and DPX. +//! +//! Targa follows the Truevision TGA File Format Specification 2.0 (image type 2, uncompressed +//! true-colour, bottom-left origin, the layout every reader accepts). DPX follows SMPTE ST 268 +//! (version 2.0 header): one RGB image element, 10 bits per component packed into 32-bit +//! big-endian words (method A, two padding bits at the bottom), BT.709 transfer and colorimetry. +use crate::{ExportError, Result}; + +/// `w * h * 4` when `rgba` holds exactly that many bytes. +fn checked_len(rgba: &[u8], w: u32, h: u32) -> Result { + let n = (w as usize).checked_mul(h as usize).and_then(|n| n.checked_mul(4)).ok_or_else(|| ExportError::Encode("frame size".into()))?; + if w == 0 || h == 0 || rgba.len() != n { + return Err(ExportError::Encode("frame size".into())); + } + Ok(n) +} + +/// The RGB bytes of an RGBA8 frame (alpha dropped). +pub(crate) fn rgb(rgba: &[u8], w: u32, h: u32) -> Result> { + checked_len(rgba, w, h)?; + Ok(rgba.as_chunks::<4>().0.iter().flat_map(|&[r, g, b, _]| [r, g, b]).collect()) +} + +/// An uncompressed Targa file: 24-bit BGR, or 32-bit BGRA with `alpha` (straight alpha, as the +/// frame carries it). +pub(crate) fn tga(rgba: &[u8], w: u32, h: u32, alpha: bool) -> Result> { + checked_len(rgba, w, h)?; + let (tw, th) = match (u16::try_from(w), u16::try_from(h)) { + (Ok(tw), Ok(th)) => (tw, th), + _ => return Err(ExportError::Unsupported(format!("Targa frames are at most 65535×65535 (got {w}×{h})"))), + }; + let bpp: usize = if alpha { 4 } else { 3 }; + let row = w as usize * 4; + let mut out = Vec::with_capacity(18 + w as usize * h as usize * bpp + 26); + out.extend_from_slice(&[0, 0, 2]); // no ID, no colour map, uncompressed true-colour + out.extend_from_slice(&[0; 5]); // colour map specification (unused) + out.extend_from_slice(&[0, 0, 0, 0]); // x / y origin + out.extend_from_slice(&tw.to_le_bytes()); + out.extend_from_slice(&th.to_le_bytes()); + out.push(if alpha { 32 } else { 24 }); + out.push(if alpha { 8 } else { 0 }); // attribute bits; bottom-left origin + // bottom-left origin: the last frame row comes first + for line in rgba.chunks_exact(row).rev() { + for &[r, g, b, a] in line.as_chunks::<4>().0 { + out.extend_from_slice(&[b, g, r]); + if alpha { + out.push(a); + } + } + } + // TGA 2.0 footer without extension or developer areas + out.extend_from_slice(&[0; 8]); + out.extend_from_slice(b"TRUEVISION-XFILE.\0"); + Ok(out) +} + +/// Header size of the DPX files we write (file + image + orientation + film + TV headers). +const DPX_HEADER: usize = 2048; + +/// A DPX file: 10-bit RGB (8-bit values widened by bit replication, so 0 → 0 and 255 → 1023). +pub(crate) fn dpx(rgba: &[u8], w: u32, h: u32) -> Result> { + let n = checked_len(rgba, w, h)?; + // one 32-bit word per pixel; `n` is already `w * h * 4` + let size = n.checked_add(DPX_HEADER).ok_or_else(|| ExportError::Encode("frame size".into()))?; + let file_size = u32::try_from(size).map_err(|_| ExportError::Unsupported(format!("a {w}×{h} DPX frame exceeds 4 GB")))?; + // SMPTE ST 268: undefined numeric fields are all ones, undefined text fields are NUL + let mut hd = vec![0xFF_u8; DPX_HEADER]; + for (a, b) in [ + (36, 660), // file name, creation time, creator, project, copyright + (664, 768), // reserved + (1356, 1408), // reserved + (1432, 1620), // source file name, time, input device, serial number + (1644, 1664), // reserved + (1664, 1712), // film manufacturer, type, offset, prefix, count, format + (1732, 1920), // frame id, slate, reserved + (1972, 2048), // reserved + ] { + hd[a..b].fill(0); + } + for e in 0..8 { + let d = 780 + e * 72 + 40; + hd[d..d + 32].fill(0); // element descriptions + } + let put32 = |hd: &mut Vec, at: usize, v: u32| hd[at..at + 4].copy_from_slice(&v.to_be_bytes()); + let put16 = |hd: &mut Vec, at: usize, v: u16| hd[at..at + 2].copy_from_slice(&v.to_be_bytes()); + // file information header + hd[0..4].copy_from_slice(b"SDPX"); + put32(&mut hd, 4, DPX_HEADER as u32); // offset to the image data + hd[8..16].copy_from_slice(b"V2.0\0\0\0\0"); + put32(&mut hd, 16, file_size); + put32(&mut hd, 20, 1); // ditto key: new frame + put32(&mut hd, 24, 1664); // generic header length + put32(&mut hd, 28, 384); // industry header length + put32(&mut hd, 32, 0); // user data length + hd[160..169].copy_from_slice(b"FilmCraft"); + // image information header + put16(&mut hd, 768, 0); // orientation: left to right, top to bottom + put16(&mut hd, 770, 1); // one image element + put32(&mut hd, 772, w); + put32(&mut hd, 776, h); + let el = 780; + put32(&mut hd, el, 0); // unsigned + put32(&mut hd, el + 4, 0); // reference low data code + put32(&mut hd, el + 12, 1023); // reference high data code + hd[el + 20] = 50; // descriptor: RGB + hd[el + 21] = 6; // transfer: ITU-R BT.709 + hd[el + 22] = 6; // colorimetric: ITU-R BT.709 + hd[el + 23] = 10; // bit depth + put16(&mut hd, el + 24, 1); // packing: filled to 32-bit words, method A + put16(&mut hd, el + 26, 0); // no encoding (uncompressed) + put32(&mut hd, el + 28, DPX_HEADER as u32); // offset to the element's data + put32(&mut hd, el + 32, 0); // end-of-line padding + put32(&mut hd, el + 36, 0); // end-of-image padding + // image orientation header: no offset, square pixels + put32(&mut hd, 1408, 0); + put32(&mut hd, 1412, 0); + put32(&mut hd, 1424, w); + put32(&mut hd, 1428, h); + put32(&mut hd, 1628, 1); + put32(&mut hd, 1632, 1); + // television header: progressive + hd[1928] = 0; + let mut out = Vec::with_capacity(size); + out.extend_from_slice(&hd); + let ten = |v: u8| (u32::from(v) << 2) | (u32::from(v) >> 6); + for &[r, g, b, _] in rgba.as_chunks::<4>().0 { + out.extend_from_slice(&((ten(r) << 22) | (ten(g) << 12) | (ten(b) << 2)).to_be_bytes()); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn be32(b: &[u8], at: usize) -> u32 { + u32::from_be_bytes(b[at..at + 4].try_into().unwrap()) + } + + #[test] + fn dpx_header_and_packing() { + // 2×1: pure red, then (1, 128, 255) + let px = [255, 0, 0, 255, 1, 128, 255, 7]; + let f = dpx(&px, 2, 1).unwrap(); + assert_eq!(&f[0..4], b"SDPX"); + assert_eq!(f.len(), 2048 + 8); + assert_eq!(be32(&f, 4), 2048); + assert_eq!(be32(&f, 16), f.len() as u32); + assert_eq!((be32(&f, 772), be32(&f, 776)), (2, 1)); + assert_eq!((f[800], f[803]), (50, 10)); + assert_eq!(be32(&f, 2048), 1023 << 22); + let p = be32(&f, 2052); + assert_eq!(((p >> 22) & 1023, (p >> 12) & 1023, (p >> 2) & 1023, p & 3), (4, 514, 1023, 0)); + } + + #[test] + fn tga_rows_are_bottom_up_bgr() { + // 1×2: top red, bottom blue (half transparent) + let px = [255, 0, 0, 255, 0, 0, 255, 128]; + let f = tga(&px, 1, 2, false).unwrap(); + assert_eq!(&f[..3], &[0, 0, 2]); + assert_eq!((f[16], f[17]), (24, 0)); + assert_eq!(&f[18..24], &[255, 0, 0, 0, 0, 255]); // blue row first, as BGR + assert!(f.ends_with(b"TRUEVISION-XFILE.\0")); + let f = tga(&px, 1, 2, true).unwrap(); + assert_eq!((f[16], f[17]), (32, 8)); + assert_eq!(&f[18..26], &[255, 0, 0, 128, 0, 0, 255, 255]); + } + + #[test] + fn hostile_sizes_are_errors() { + assert!(dpx(&[0; 7], 2, 1).is_err()); + assert!(dpx(&[], 0, 0).is_err()); + assert!(tga(&[0; 4], 1, 2, false).is_err()); + assert!(tga(&[0; 4], u32::MAX, u32::MAX, false).is_err()); + assert!(rgb(&[0; 3], 1, 1).is_err()); + assert!(dpx(&[0; 4], u32::MAX, u32::MAX).is_err()); + } +} diff --git a/crates/export/src/settings.rs b/crates/export/src/settings.rs index 90dd595f..4e062d2a 100644 --- a/crates/export/src/settings.rs +++ b/crates/export/src/settings.rs @@ -378,11 +378,11 @@ impl ExportSettings { if self.format.is_mxf() { self.mxf_video_codec.encoder_format() } else { self.format } } - /// Whether the output can carry an alpha channel ([`ExportSettings::alpha`]): PNG and TIFF - /// sequences and ProRes 4444 / 4444 XQ QuickTime movies. + /// Whether the output can carry an alpha channel ([`ExportSettings::alpha`]): PNG, TIFF and + /// Targa sequences and ProRes 4444 / 4444 XQ QuickTime movies. pub fn supports_alpha(&self) -> bool { match self.format { - Format::PngSequence | Format::TiffSequence => true, + Format::PngSequence | Format::TiffSequence | Format::TgaSequence => true, Format::ProRes => crate::prores_profile(&self.prores_profile).chroma() == filmcraft_prores::ChromaFormat::Yuv444, _ => false, } @@ -395,7 +395,7 @@ impl ExportSettings { /// Whether the output is a numbered image sequence. pub fn is_image_sequence(&self) -> bool { - matches!(self.format, Format::PngSequence | Format::TiffSequence | Format::BmpSequence) + self.format.is_image_sequence() } /// Output size, rate, audio format and bitrate for a sequence of `seq_w`×`seq_h` at `seq_rate`. @@ -482,9 +482,12 @@ impl ExportSettings { }; px * fps * bpp } - Format::Mjpeg => px * 8.0 * (0.4 + self.quality as f64 / 100.0 * 2.0) * fps / 8.0, + Format::Mjpeg | Format::JpegSequence => px * 8.0 * (0.4 + self.quality as f64 / 100.0 * 2.0) * fps / 8.0, Format::PngSequence => px * 4.0 * 0.45 * 8.0 * fps, Format::TiffSequence | Format::BmpSequence => px * if self.format == Format::BmpSequence { 3.0 } else { 4.0 } * 8.0 * fps, + Format::TgaSequence => px * if self.keeps_alpha() { 4.0 } else { 3.0 } * 8.0 * fps, + // 10-bit RGB in one 32-bit word per pixel + Format::DpxSequence => px * 4.0 * 8.0 * fps, Format::Gif => px * 0.6 * 8.0 * fps, Format::Wav | Format::Aiff | Format::MxfOp1a | Format::MxfOpAtom => 0.0, }; @@ -566,7 +569,8 @@ impl ExportSettings { } Format::DnxHr => v += &format!(", DNxHR {}", if self.dnx_profile.is_empty() { "HQ".into() } else { self.dnx_profile.to_ascii_uppercase() }), Format::Apv => v += &format!(", {}", crate::apv_profile(&self.apv_profile).name()), - Format::Mjpeg => v += &format!(", quality {}", self.quality), + Format::Mjpeg | Format::JpegSequence => v += &format!(", quality {}", self.quality), + Format::DpxSequence => v += ", 10-bit RGB", _ => {} } v @@ -589,7 +593,7 @@ impl ExportSettings { Format::H264 | Format::Hevc | Format::Av1 => "MP4", Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg => "QuickTime", Format::MxfOp1a | Format::MxfOpAtom => self.mxf_video_codec.label(), - Format::PngSequence | Format::TiffSequence | Format::BmpSequence => "Image sequence", + f if f.is_image_sequence() => "Image sequence", _ => "", }; let format = if container.is_empty() { self.format.label().to_string() } else { format!("{} ({container})", self.format.label()) }; diff --git a/crates/export/src/settings_tests.rs b/crates/export/src/settings_tests.rs index d507639d..8cfb3170 100644 --- a/crates/export/src/settings_tests.rs +++ b/crates/export/src/settings_tests.rs @@ -109,6 +109,57 @@ fn image_sequences_are_numbered_like_premiere() { } } +/// JPEG, Targa and DPX sequences (#779): numbered like the others, JPEG at the export quality, +/// Targa with alpha when asked; every still decoded by ffmpeg (external test oracle). +#[test] +fn jpeg_targa_and_dpx_sequences() { + let (p, seq, m) = matte([0.0, 0.0, 1.0, 0.5], 64, 36, None); + let mut jpeg_sizes = Vec::new(); + for (fmt, ext, alpha, quality) in [ + (Format::JpegSequence, "jpg", false, 95), + (Format::JpegSequence, "jpg", false, 20), + (Format::TgaSequence, "tga", false, 90), + (Format::TgaSequence, "tga", true, 90), + (Format::DpxSequence, "dpx", false, 90), + ] { + let dir = Scratch::new(&format!("seq3-{ext}-{alpha}-{quality}")); + let mut s = ExportSettings { format: fmt, path: dir.path(&format!("Shot.{ext}")), alpha, quality, ..Default::default() }; + s.range = Some(TimeRange::new(Tick::ZERO, FrameRate::FPS_24.tick_of(3))); + assert_eq!(Format::from_name(fmt.id()), Some(fmt)); + assert!(fmt.is_image_sequence() && s.is_image_sequence() && !s.has_audio()); + let r = export(&p, seq, &s, &m, &Progress::default()).unwrap(); + assert_eq!(r.frames, 3); + let want: Vec = (0..3).map(|i| format!("Shot{i:03}.{ext}")).collect(); + assert_eq!(files_in(&dir.0), want, "{fmt:?}"); + let first = dir.0.join(&want[0]); + let bytes = std::fs::read(&first).unwrap(); + match fmt { + Format::JpegSequence => { + let px = image::load_from_memory(&bytes).unwrap().to_rgb8().get_pixel(32, 18).0; + assert!(px[2] > 100 && px[0] < 20, "{px:?}"); + jpeg_sizes.push(bytes.len()); + } + Format::DpxSequence => assert_eq!(bytes.len(), 2048 + 64 * 36 * 4), + _ => assert_eq!(bytes.len(), 18 + 64 * 36 * if alpha { 4 } else { 3 } + 26), + } + let Some(ffmpeg) = filmcraft_testkit::ffmpeg_or_skip("jpeg / targa / dpx sequences") else { continue }; + let first = first.to_string_lossy().to_string(); + let out = std::process::Command::new(ffmpeg).args(["-v", "error", "-i", &first, "-f", "rawvideo", "-pix_fmt", "rgba", "-"]).output().unwrap(); + assert!(out.status.success(), "{first}: {}", String::from_utf8_lossy(&out.stderr)); + assert_eq!(out.stdout.len(), 64 * 36 * 4, "{first}"); + let at = (18 * 64 + 32) * 4; + let px = &out.stdout[at..at + 4]; + if alpha { + assert!((100..=160).contains(&px[3]) && px[2] > 200 && px[0] < 15, "{first} straight alpha: {px:?}"); + } else { + assert!(px[3] == 255 && px[2] > 100 && px[2] < 240 && px[0] < 20, "{first} flattened over black: {px:?}"); + } + // the top-left pixel is the top row (Targa is stored bottom-up), and all of it is blue + assert!(out.stdout.as_chunks::<4>().0.iter().all(|p| p[2] > 100 && p[0] < 20), "{first}"); + } + assert!(jpeg_sizes[0] > jpeg_sizes[1], "quality 95 must be larger than quality 20: {jpeg_sizes:?}"); +} + /// `alpha` keeps straight alpha in PNG and TIFF sequences; off (the default) flattens over black (#160). #[test] fn image_sequences_keep_alpha_when_asked() { @@ -607,7 +658,7 @@ fn h265_is_a_format_that_needs_a_registered_encoder() { } assert_eq!(Format::from_name(Format::Hevc.id()), Some(Format::Hevc)); assert_eq!((Format::Hevc.extension(), Format::Hevc.label()), ("mp4", "H.265 (HEVC)")); - assert!(Format::ALL.contains(&Format::Hevc) && Format::ALL.len() == 15); + assert!(Format::ALL.contains(&Format::Hevc) && Format::ALL.len() == 18); let s: ExportSettings = serde_json::from_value(serde_json::json!({"format": "hevc"})).unwrap(); assert_eq!(s.format, Format::Hevc); assert_eq!(serde_json::to_value(&s).unwrap()["format"], "hevc"); diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index f55b5226..51d1cd03 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3173,6 +3173,15 @@ Nudge Volume -1dB Ajustar volumen -1 dB Nudge Volume -3dB Ajustar volumen -3 dB Numbered BMP stills (24-bit) Imágenes fijas BMP numeradas (24 bits) + JPEG JPEG + Targa Targa + DPX DPX + JPEG Sequence Secuencia JPEG + Targa Sequence Secuencia Targa + DPX Sequence Secuencia DPX + Numbered JPEG stills (quality 90) Imágenes fijas JPEG numeradas (calidad 90) + Numbered Targa stills (24-bit, uncompressed) Imágenes fijas Targa numeradas (24 bits, sin comprimir) + Numbered DPX stills (10-bit RGB) Imágenes fijas DPX numeradas (RGB de 10 bits) Numbered PNG stills at the sequence's frame size Imágenes fijas PNG numeradas al tamaño de fotograma de la secuencia Numbered TIFF stills (RGB, 8-bit) Imágenes fijas TIFF numeradas (RGB, 8 bits) Opacity 0 → 100 % over the first second Opacidad de 0 a 100 % durante el primer segundo diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 65419ebc..79c7d025 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3162,6 +3162,15 @@ Nudge Volume -1dB 音量を -1dB 微調整 Nudge Volume -3dB 音量を -3dB 微調整 Numbered BMP stills (24-bit) 連番 BMP 静止画 (24-bit) + JPEG JPEG + Targa Targa + DPX DPX + JPEG Sequence JPEG 連番画像 + Targa Sequence Targa 連番画像 + DPX Sequence DPX 連番画像 + Numbered JPEG stills (quality 90) 連番 JPEG 静止画 (品質 90) + Numbered Targa stills (24-bit, uncompressed) 連番 Targa 静止画 (24-bit、非圧縮) + Numbered DPX stills (10-bit RGB) 連番 DPX 静止画 (10-bit RGB) Numbered PNG stills at the sequence's frame size シーケンスと同じフレームサイズの連番 PNG 静止画 Numbered TIFF stills (RGB, 8-bit) 連番 TIFF 静止画 (RGB、8-bit) Opacity 0 → 100 % over the first second 最初の 1 秒で不透明度を 0 → 100 % に変化 diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index fc805435..16472ef0 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -3156,6 +3156,15 @@ Nudge Volume -1dB 微调音量 -1dB Nudge Volume -3dB 微调音量 -3dB Numbered BMP stills (24-bit) 带编号的 BMP 静帧(24 位) + JPEG JPEG + Targa Targa + DPX DPX + JPEG Sequence JPEG 序列 + Targa Sequence Targa 序列 + DPX Sequence DPX 序列 + Numbered JPEG stills (quality 90) 带编号的 JPEG 静帧(质量 90) + Numbered Targa stills (24-bit, uncompressed) 带编号的 Targa 静帧(24 位,未压缩) + Numbered DPX stills (10-bit RGB) 带编号的 DPX 静帧(10 位 RGB) Numbered PNG stills at the sequence's frame size 带编号的 PNG 静帧(采用序列帧大小) Numbered TIFF stills (RGB, 8-bit) 带编号的 TIFF 静帧(RGB,8 位) Opacity 0 → 100 % over the first second 在最初一秒内不透明度从 0 → 100% diff --git a/crates/ui-egui/src/panels/export_mode.rs b/crates/ui-egui/src/panels/export_mode.rs index 426da87c..30d21268 100644 --- a/crates/ui-egui/src/panels/export_mode.rs +++ b/crates/ui-egui/src/panels/export_mode.rs @@ -743,7 +743,7 @@ fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T } }); } - Format::Mjpeg => { + Format::Mjpeg | Format::JpegSequence => { row(ui, t, tl!("Quality"), |ui| { let mut q = s.quality as f64; drag(ui, reg, "export.video.quality", &mut q, 1.0..=100.0, 1.0, "", 0); diff --git a/docs/architecture.md b/docs/architecture.md index 24a62065..ed1486af 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -666,7 +666,8 @@ file.exportMedia {path, preset?, settings?, format?, range?, …} export.qui | `mjpeg` | built in | MOV | | `mxf-op1a` | DNxHR (default), ProRes or H.264 (Annex B, long GOP), `mxfVideoCodec`; PCM 16/24-bit | MXF OP1a (`filmcraft-mxf` writer: frame-wrapped, index with temporal offsets, start timecode) | | `mxf-opatom` | as `mxf-op1a` | Avid-style MXF OP-Atom: clip-wrapped picture at `path`, one mono PCM file per channel (`_A1.mxf` …, `Report::extra_files`) | -| `png`, `tiff`, `bmp` | `image` | numbered stills `000.`, `001.` … | +| `png`, `tiff`, `bmp`, `jpg` | `image` | numbered stills `000.`, `001.` … | +| `tga`, `dpx` | built in (`raster.rs`: uncompressed Targa 24/32-bit; DPX 10-bit RGB, SMPTE ST 268) | numbered stills, as above | | `gif`, `wav`, `aiff` | built in / `image` | GIF / RIFF WAVE (`WAVE_FORMAT_EXTENSIBLE` for 5.1) / AIFF (16- or 24-bit PCM) | - **Reproducible.** The same project and settings give the same file on every machine. Frames render diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b19262c8..58c45cb6 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -52,12 +52,12 @@ only). "Tested" names the evidence. | Format | Premiere | Read | Write (frame / sequence) | % | |---|---|---|---|---| -| PNG, JPEG, TIFF, BMP, GIF | read + write | yes | PNG / TIFF / BMP sequences, GIF; **no JPEG sequence** | 80% | +| PNG, JPEG, TIFF, BMP, GIF | read + write | yes | PNG / TIFF / BMP / JPEG sequences, GIF | 90% | | WebP | — | yes | no | ahead | | **PSD** (layers as sequence) | read (layered) | no | — | 0% | | **OpenEXR** | read + write | no | no | 0% | -| **DPX** | read + write | no | no | 0% | -| Targa | read + write | no | no | 0% | +| **DPX** | read + write | no | sequences (10-bit RGB, BT.709; #779) | 40% | +| Targa | read + write | no | sequences (24-bit, 32-bit with alpha; #779) | 40% | | HEIF / HEIC | read | no | — | 0% | | Radiance HDR, AI / EPS, ICO, PTL | read | no | — | 0% | diff --git a/docs/gaps.md b/docs/gaps.md index 3532ae7b..7ab42d05 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -178,9 +178,10 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** ## G13. Delivery formats - **Missing:** AV1 export (software or hardware), MPEG-2 / DVD / Blu-ray, DCP, JPEG 2000 MXF, - AS-10 / AS-11, XDCAM HD MXF, P2, EXR / DPX / Targa / JPEG sequences, MP3 and AAC-only audio, + AS-10 / AS-11, XDCAM HD MXF, P2, OpenEXR sequences, MP3 and AAC-only audio, ProRes 4444 / XQ in the export UI (#342; the encoder has the profiles), interlaced encoding, smart render, HEVC software encoder (HEVC exists only where a hardware encoder does). +- **Have:** PNG, TIFF, BMP, JPEG, Targa and DPX (10-bit) image sequences (#779). - **Estimate:** 60–110 h. Doc: [file-format-parity.md](file-format-parity.md). ## G14. Localization: 8 of the 12 key languages missing From c7ad44bc1987cfc8d7a68429cac254ccae2a5a55 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 04:55:59 +0700 Subject: [PATCH 132/144] test(menus): normalize regression test spacing --- crates/ui-egui/src/menus.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 7bb32e87..9a9c4011 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -881,7 +881,6 @@ mod parse_shortcut_tests { assert_eq!(label_menu_swatch("edit.label.invalid", &labels), None); } - /// Off macOS `Ctrl` and `Cmd` are the same key, so either spelling parses to the modifiers a /// physical Ctrl press carries and matches a binding written the other way (#245). #[test] From 0b62ebb9ad7bcfdf200adb4210c88247033e756d Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 00:08:40 +0200 Subject: [PATCH 133/144] #577: Effect Controls shows a selected transition's own timeline Problem: with a transition selected, Effect Controls showed only its settings. Premiere puts the transition's own timeline beside them (clip A, clip B and the transition over their cut), where it can be slid over the cut or resized by dragging; FilmCraft could do that only in the Timeline. Fix: right of the settings (the clip view's divider and width), a time ruler over the cut, clip A above, the transition between, clip B below, the cut and the playhead. Dragging the transition's middle slides it, dragging an end changes its duration from that end (the Timeline's rules, transitions::drag_span), measured against the view the drag began in; each drag is one undoable sequence.setTransition. The ruler moves the playhead. Every part has an automation id; no new UI strings. Co-Authored-By: Claude Opus 5.5 --- .../ui-egui/src/panels/transition_controls.rs | 257 +++++++++++++++++- crates/ui-egui/tests/transitions_ui.rs | 70 +++++ docs/gaps.md | 3 +- docs/ui-parity.md | 2 +- 4 files changed, 323 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/panels/transition_controls.rs b/crates/ui-egui/src/panels/transition_controls.rs index 47decaf2..0f5c4760 100644 --- a/crates/ui-egui/src/panels/transition_controls.rs +++ b/crates/ui-egui/src/panels/transition_controls.rs @@ -4,25 +4,45 @@ //! the Timeline opens. Every change is one `sequence.setTransition`; dragging a value is one undo //! step. //! +//! Right of the settings, as in Premiere, is the transition's own timeline (#577): a time ruler over +//! the cut, the outgoing clip (A) above, the incoming clip (B) below and the transition between +//! them. Dragging the transition's middle slides it over the cut; dragging one of its ends changes +//! its duration from that end, the other staying put (the Timeline's rules, +//! [`trn::drag_span`]); each drag is one undo step. Clicking or dragging the ruler moves the +//! playhead. The divider between the settings and the timeline is the clip view's. +//! //! Not here yet: the A/B preview thumbnails with their Start / End sliders, Show Actual Sources, -//! and dragging the transition in Effect Controls' own time ruler (the Timeline does that). +//! and a rolling edit by dragging clip A or B in the transition's timeline. //! //! Automation ids: `effectControls.transition.duration`, `.alignment` (+ `.alignment.option.` while open), `.reverse`, `.reset`, `.param.` (a point's are `.param..x` / -//! `.y`; a list's entries `.param..option.` while open); the dialog's -//! `transitionDuration.value`, `.ok`, `.cancel`. +//! `.y`; a list's entries `.param..option.` while open); its timeline's +//! `effectControls.transition.timeline`, `.timeline.ruler`, `.timeline.a` / `.timeline.b` (the +//! clips, labelled with their names), `.timeline.span` (the transition) with its ends +//! `.timeline.span.in` / `.timeline.span.out`, `.timeline.playhead`; `effectControls.divider`; +//! the dialog's `transitionDuration.value`, `.ok`, `.cancel`. -use egui::{Rect, RichText, vec2}; +use egui::{Align2, Pos2, Rect, RichText, Sense, Stroke, pos2, vec2}; +use filmcraft_edit::Edge; use filmcraft_edit::transitions::{self as trn, Alignment}; use filmcraft_project::{ParamKind, ParamValue, Transition, TransitionId}; -use filmcraft_time::FrameRate; +use filmcraft_time::{FrameRate, Tick}; use serde_json::{Value, json}; use crate::FilmcraftApp; use crate::state::TransitionDurationDraft; +use crate::theme::Tokens; type Elems = Vec<(String, Rect, String)>; +// the same sizes as the clip view's (`effect_controls.rs`), so the two look alike +const MIN_LIST_W: f32 = 260.0; +const MIN_LANE_W: f32 = 60.0; +const RULER_H: f32 = 24.0; +const BAR_H: f32 = 18.0; +/// How far into the transition from either end a press grabs that end (the Timeline's width). +const EDGE_PX: f32 = 7.0; + /// The selected transition, when it still exists, with its alignment and how many frames it may /// last at most (the clips it joins). fn lookup(app: &FilmcraftApp, id: TransitionId) -> Option<(Transition, Alignment, i64)> { @@ -71,13 +91,23 @@ pub fn effect_controls(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, id let one_sided = x.from.is_none() || x.to.is_none(); let mut changes: Vec = Vec::new(); let mut elems: Elems = Vec::new(); - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(vec2(10.0, 6.0))).id_salt(("tr-ec", id.0))); + // the settings on the left, the transition's timeline right of the divider (the clip view's + // divider and width, #643); a panel too narrow for both shows the settings only + let max_list = (rect.width() - MIN_LANE_W - 10.0).max(MIN_LIST_W); + let list_w = if app.ui.effect_controls_split > 0.0 { app.ui.effect_controls_split } else { (rect.width() * 0.58).max(260.0) }; + let list_w = list_w.clamp(MIN_LIST_W, max_list); + let split = rect.min.x + list_w; + let lane = Rect::from_min_max(pos2(split + 4.0, rect.min.y + 4.0), pos2(rect.max.x - 6.0, rect.max.y - 6.0)); + let has_lane = lane.width() >= MIN_LANE_W / 2.0 && lane.height() >= RULER_H + 3.0 * BAR_H; + let list = if has_lane { Rect::from_min_max(rect.min, pos2(split, rect.max.y)) } else { rect }; + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(list.shrink2(vec2(10.0, 6.0))).id_salt(("tr-ec", id.0))); + child.set_clip_rect(list.intersect(ui.clip_rect())); child.label(RichText::new(format!("{seq_name} · {name}")).color(t.text_dim)); child.add_space(4.0); // the line under each row, as for a clip's effects (#640): halfway into the row spacing let row_line = |ui: &egui::Ui| { let y = ui.cursor().top() - 4.0; - ui.painter().line_segment([egui::pos2(rect.min.x, y), egui::pos2(rect.max.x, y)], egui::Stroke::new(1.0, t.separator)); + ui.painter().line_segment([pos2(list.min.x, y), pos2(list.max.x, y)], Stroke::new(1.0, t.separator)); }; egui::ScrollArea::vertical().id_salt(("tr-ec-scroll", id.0)).auto_shrink([false, false]).show(&mut child, |ui| { egui::Grid::new(("tr-ec-grid", id.0)).num_columns(2).spacing([14.0, 8.0]).show(ui, |ui| { @@ -141,6 +171,19 @@ pub fn effect_controls(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, id changes.push(json!({"reset": true})); } }); + if has_lane { + if let Some(c) = timeline(app, ui, lane, id, &x, &mut elems) { + changes.push(c); + } + let divider = Rect::from_min_max(pos2(split - 2.0, rect.min.y), pos2(split + 4.0, rect.max.y)); + let dresp = ui.interact(divider, egui::Id::new("tr-ec-divider"), Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal); + if dresp.dragged() { + app.ui.effect_controls_split = (list_w + dresp.drag_delta().x).clamp(MIN_LIST_W, max_list); + } + let dcol = if dresp.hovered() || dresp.dragged() { t.accent } else { t.separator }; + ui.painter().line_segment([pos2(split + 1.5, rect.min.y), pos2(split + 1.5, rect.max.y)], Stroke::new(1.0, dcol)); + elems.push(("effectControls.divider".into(), divider, "divider".into())); + } for (eid, r, l) in elems { app.auto.add(&eid, r, &l); } @@ -152,6 +195,206 @@ pub fn effect_controls(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, id } } +/// A drag in the transition's timeline: the end it grabbed (`None`: the middle), where the +/// transition was when it began, and the stretch of time the lane showed then, so the drag is +/// measured against a ruler that holds still while the transition changes under it. `begun` once +/// its first change went through: the rest merge into that undo step. +#[derive(Clone, Copy, Debug)] +struct LaneDrag { + edge: Option, + start: Tick, + duration: Tick, + view: (Tick, Tick), + from_x: f32, + begun: bool, +} + +/// The stretch of time the transition's timeline shows: its cut with one and a half times the +/// transition's duration either side, room for it wherever it sits over the cut. +fn view(x: &Transition, cut: Tick, frame: Tick) -> (Tick, Tick) { + let half = x.duration.max(frame).max(Tick(1)).mul_ratio(3, 2); + (cut - half, cut + half) +} + +/// Where `tk` falls on `lane`, which shows `view`. +fn x_of(lane: Rect, view: (Tick, Tick), tk: Tick) -> f32 { + let span = (view.1 - view.0).0.max(1) as f64; + let f = ((tk - view.0).0 as f64 / span).clamp(-100.0, 100.0) as f32; + // far outside the view stays a finite number of lane widths away + lane.min.x + f * lane.width() +} + +/// The time under `px` on `lane`, which shows `view`. +fn tick_at(lane: Rect, view: (Tick, Tick), px: f32) -> Tick { + let span = (view.1 - view.0).0.max(1) as f64; + // `as` saturates (and a NaN is 0), so a hostile width can't overflow + view.0 + Tick((f64::from((px - lane.min.x) / lane.width().max(1.0)) * span) as i64) +} + +/// Ticks per `px` points of `lane` showing `view`, as a time delta. +fn ticks_for(lane: Rect, view: (Tick, Tick), px: f32) -> Tick { + let span = (view.1 - view.0).0.max(1) as f64; + Tick((f64::from(px / lane.width().max(1.0)) * span) as i64) +} + +/// The selected transition's own timeline, right of its settings: ruler, clip A, the transition, +/// clip B, the cut and the playhead. Returns the change a drag of the transition makes this frame, +/// as `sequence.setTransition` params (without the id); moves the playhead itself. +fn timeline(app: &mut FilmcraftApp, ui: &mut egui::Ui, lane: Rect, id: TransitionId, x: &Transition, elems: &mut Elems) -> Option { + let t = app.tokens; + let seq = app.session.active_sequence()?; + let (rate, drop_frame) = (seq.settings.frame_rate.sane(), seq.settings.drop_frame); + let frame = rate.frame_duration(); + let track = seq.all_tracks().find(|tr| tr.transitions.iter().any(|y| y.id == id))?; + let (_, cut, _) = trn::bounds(track, x)?; + let clip_a = x.from.and_then(|c| track.item(c)).map(|it| (it.start, it.end(), it.name.clone())); + let clip_b = x.to.and_then(|c| track.item(c)).map(|it| (it.start, it.end(), it.name.clone())); + let mem = egui::Id::new(("tr-ec-lane-drag", id.0)); + let mut drag: Option = ui.data(|d| d.get_temp(mem)); + let v = drag.map_or_else(|| view(x, cut, frame), |d| d.view); + let painter = ui.painter().with_clip_rect(lane.intersect(ui.clip_rect())); + painter.rect_filled(lane, 0.0, t.tl_bg); + let ruler = Rect::from_min_max(lane.min, pos2(lane.max.x, lane.min.y + RULER_H)); + paint_ruler(&painter, ruler, v, rate, drop_frame, &t); + // the rows: A on top, the transition between, B under it, as Premiere stacks them + let row = |i: f32| { + let top = ruler.max.y + 4.0 + i * (BAR_H + 2.0); + Rect::from_min_max(pos2(lane.min.x, top), pos2(lane.max.x, top + BAR_H)) + }; + let (row_a, row_x, row_b) = (row(0.0), row(1.0), row(2.0)); + for ((clip, r), key) in [(&clip_a, row_a), (&clip_b, row_b)].into_iter().zip(["a", "b"]) { + let Some((s, e, name)) = clip else { continue }; + let bar = Rect::from_min_max(pos2(x_of(lane, v, *s).max(lane.min.x), r.min.y), pos2(x_of(lane, v, *e).min(lane.max.x), r.max.y)); + if bar.width() <= 0.0 { + continue; + } + painter.rect_filled(bar, 2.0, t.clip_bar_bg); + painter.with_clip_rect(bar.intersect(lane)).text(pos2(bar.min.x + 4.0, bar.center().y), Align2::LEFT_CENTER, name, Tokens::ui(10.0), t.text); + elems.push((format!("effectControls.transition.timeline.{key}"), bar, name.clone())); + } + // the transition, outlined as selected (it is), with the Timeline's end zones + let (x0, x1) = (x_of(lane, v, x.start), x_of(lane, v, x.end())); + let span = Rect::from_min_max(pos2(x0, row_x.min.y), pos2(x1.max(x0 + 1.0), row_x.max.y)); + painter.rect_filled(span, 0.0, egui::Color32::from_black_alpha(90)); + painter.rect_stroke(span, 0.0, Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); + let name = x.effect.def().map_or(x.effect.effect.as_str(), |d| d.name); + if span.width() > 30.0 { + painter.with_clip_rect(span.shrink(2.0).intersect(lane)).text( + pos2(span.min.x + 5.0, span.center().y), + Align2::LEFT_CENTER, + crate::i18n::t(name), + Tokens::ui(10.0), + t.text, + ); + } + let zone = EDGE_PX.min(span.width() / 3.0); + elems.push(("effectControls.transition.timeline.span".into(), span, name.to_string())); + elems.push(("effectControls.transition.timeline.span.in".into(), Rect::from_min_max(span.min, pos2(span.min.x + zone, span.max.y)), "start".into())); + elems.push(("effectControls.transition.timeline.span.out".into(), Rect::from_min_max(pos2(span.max.x - zone, span.min.y), span.max), "end".into())); + // the cut, through all three rows + let cx = x_of(lane, v, cut); + painter.line_segment([pos2(cx, row_a.min.y), pos2(cx, row_b.max.y)], Stroke::new(1.0, t.text_dim)); + // the playhead, while it is in view + let ph = app.session.playhead(); + if ph >= v.0 && ph <= v.1 { + let px = x_of(lane, v, ph); + let (top, tip) = (ruler.max.y - 13.0, ruler.max.y); + let head = vec![pos2(px - 5.0, top), pos2(px + 5.0, top), pos2(px + 5.0, tip - 5.0), pos2(px, tip), pos2(px - 5.0, tip - 5.0)]; + painter.add(egui::Shape::convex_polygon(head, t.playhead, Stroke::NONE)); + painter.line_segment([pos2(px, tip), pos2(px, lane.max.y)], Stroke::new(1.0, t.playhead)); + elems.push(("effectControls.transition.timeline.playhead".into(), Rect::from_min_max(pos2(px - 5.0, top), pos2(px + 5.0, tip)), "playhead".into())); + } + elems.push(("effectControls.transition.timeline".into(), lane, "transition timeline".into())); + elems.push(("effectControls.transition.timeline.ruler".into(), ruler, "time ruler".into())); + // the ruler moves the playhead (once the sequence is no longer borrowed, below) + let mut seek = None; + let rresp = ui.interact(ruler, egui::Id::new(("tr-ec-ruler", id.0)), Sense::click_and_drag()); + if (rresp.dragged() || rresp.clicked()) + && let Some(pos) = rresp.interact_pointer_pos() + { + seek = Some(rate.snap_nearest(tick_at(lane, v, pos.x.clamp(lane.min.x, lane.max.x))).max(Tick::ZERO)); + } + // the transition: its middle slides it, its ends change its duration + let grab = |px: f32| -> Option { + if px < span.min.x + zone { + Some(Edge::In) + } else if px > span.max.x - zone { + Some(Edge::Out) + } else { + None + } + }; + let sresp = ui.interact(span, egui::Id::new(("tr-ec-span", id.0)), Sense::click_and_drag()); + if let Some(h) = sresp.hover_pos() { + ui.ctx().set_cursor_icon(if grab(h.x).is_some() { egui::CursorIcon::ResizeColumn } else { egui::CursorIcon::Grab }); + } + if sresp.drag_started() { + let from = ui.input(|i| i.pointer.press_origin()).or(sresp.interact_pointer_pos()).map_or(span.center().x, |p: Pos2| p.x); + drag = Some(LaneDrag { edge: grab(from), start: x.start, duration: x.duration, view: v, from_x: from, begun: false }); + } + let mut change = None; + if sresp.dragged() + && let Some(d) = drag.as_mut() + && let Some(pos) = sresp.interact_pointer_pos() + { + let delta = rate.snap_nearest(ticks_for(lane, d.view, pos.x - d.from_x)); + let was = Transition { start: d.start, duration: d.duration, ..x.clone() }; + if let Some((st, du)) = trn::drag_span(track, &was, d.edge, delta, frame) + && (st, du) != (x.start, x.duration) + { + change = Some(json!({"start": st.0, "duration": du.0, "merge": true, "begin": !d.begun})); + d.begun = true; + } + } + // released, or a drag that lost its transition (deleted, deselected) while it went on + if !sresp.dragged() && !sresp.drag_started() { + drag = None; + } + if let Some(tk) = seek { + app.stop(); + app.session.set_playhead(tk); + } + ui.data_mut(|m| match drag { + Some(d) => { + m.insert_temp(mem, d); + } + None => m.remove::(mem), + }); + change +} + +/// The timeline's ruler: ticks and sequence timecode across `view` (the clip view's spacing). +fn paint_ruler(p: &egui::Painter, ruler: Rect, view: (Tick, Tick), rate: FrameRate, drop_frame: bool, t: &Tokens) { + let p = p.with_clip_rect(ruler.intersect(p.clip_rect())); + let span = (view.1 - view.0).0.max(1) as f64; + let frame_px = f64::from(ruler.width()) * rate.frame_duration().0.max(1) as f64 / span; + let base = rate.timecode_base(); + // frames, then seconds (a rate's timecode base) multiplied up; saturating, as a damaged rate may be huge + let steps: Vec = [1, 2, 5, 10, base / 2, base].into_iter().chain([2, 5, 10, 30, 60, 300, 600, 3600].map(|m| base.saturating_mul(m))).collect(); + // labels far enough apart to read; the small ticks divide the labelled ones evenly + let label_step = steps.iter().copied().find(|s| *s > 0 && *s as f64 * frame_px >= 80.0); + let minor = steps.iter().copied().find(|s| *s > 0 && *s as f64 * frame_px >= 8.0 && label_step.is_none_or(|l| l % s == 0)); + let (f0, f1) = (rate.frame_at(view.0), rate.frame_at(view.1)); + let base_y = ruler.max.y - 1.0; + for (step, h, labelled) in [(minor, 3.0, false), (label_step, 7.0, true)] { + let Some(step) = step else { continue }; + let mut f = f0.div_euclid(step).saturating_mul(step); + // a step is at least 8 px wide, so this covers any ruler; the cap is for damaged numbers + for _ in 0..4096 { + if f > f1 { + break; + } + let x = x_of(ruler, view, rate.tick_of(f)); + p.line_segment([pos2(x, base_y - h), pos2(x, base_y)], Stroke::new(1.0, t.tl_ruler_tick)); + if labelled && f >= 0 { + let label = filmcraft_time::format_time(rate.tick_of(f), rate, drop_frame, filmcraft_time::TimeDisplay::Timecode, 48000); + p.text(pos2(x, ruler.min.y + 7.0), Align2::CENTER_CENTER, label, Tokens::ui(10.0), t.tl_ruler_text); + } + f = f.saturating_add(step); + } + } +} + /// One of a transition's own settings. Returns the change, as `sequence.setTransition` params. fn param_widget(ui: &mut egui::Ui, elems: &mut Elems, pid: &str, kind: &ParamKind, value: &ParamValue, frame: (f64, f64)) -> Option { let eid = format!("effectControls.transition.param.{pid}"); diff --git a/crates/ui-egui/tests/transitions_ui.rs b/crates/ui-egui/tests/transitions_ui.rs index fbcbabb5..bae155c9 100644 --- a/crates/ui-egui/tests/transitions_ui.rs +++ b/crates/ui-egui/tests/transitions_ui.rs @@ -274,3 +274,73 @@ fn transition_settings_in_effect_controls() { let want = serde_json::to_value(&def.instance().params.get("direction").unwrap().value).unwrap(); assert_eq!(d.transition(id).unwrap()["params"]["direction"], want, "Reset puts Push's own defaults back"); } + +/// Effect Controls' own timeline for a selected transition (#577): clip A, clip B and the +/// transition over their cut. Dragging its end changes its duration from that end, dragging its +/// middle slides it over the cut, each drag one undo step; the ruler moves the playhead. +#[test] +fn transition_timeline_in_effect_controls() { + let mut d = Driver::demo(); + let c = d.crossing(); + let frame = frame_ticks(d.rate()); + d.click(&format!("timeline.transition.{}", c.id)); + // both clips are shown, by name + let q = d.exec("sequence.inspect", json!({})); + let names: Vec = q["video"] + .as_array() + .unwrap() + .iter() + .flat_map(|t| t["items"].as_array().unwrap().clone()) + .filter(|i| i["start"].as_i64().unwrap() < c.cut + c.duration && i["start"].as_i64().unwrap() + i["duration"].as_i64().unwrap() > c.cut - c.duration) + .map(|i| i["name"].as_str().unwrap_or_default().to_string()) + .collect(); + let (a, b) = (d.label("effectControls.transition.timeline.a"), d.label("effectControls.transition.timeline.b")); + assert!(names.contains(&a) && names.contains(&b), "A {a:?} and B {b:?} are the clips at the cut: {names:?}"); + d.snapshot("transition-effect-controls-timeline"); + // its end: drag it right + let (x, y, w, h, _) = d.find("effectControls.transition.timeline.span.out").expect("the transition's end in Effect Controls"); + let (fx, fy) = (x + w / 2.0, y + h / 2.0); + d.ok("ui.drag", json!({"from": {"x": fx, "y": fy}, "to": {"x": fx + 30.0, "y": fy}, "steps": 8})); + d.frames(3); + let x1 = d.transition(c.id).unwrap(); + let (s1, d1) = (x1["start"].as_i64().unwrap(), x1["duration"].as_i64().unwrap()); + assert!(d1 > c.duration, "dragging the end out lengthens it: {} -> {d1}", c.duration); + assert_eq!(s1, c.start, "the start stays where it was"); + assert_eq!((d1 - c.duration) % frame, 0, "by whole frames"); + let (num, den) = d.rate(); + let rate = filmcraft_engine::time::FrameRate { num, den }; + assert_eq!( + d.label("effectControls.transition.duration"), + filmcraft_engine::time::format_timecode_frames(d1 / frame, rate, false), + "the Duration field follows" + ); + // its middle: slide it left (more than egui's click tolerance) + let (x, y, w, h, _) = d.find("effectControls.transition.timeline.span").unwrap(); + let (mx, my) = (x + w / 2.0, y + h / 2.0); + d.ok("ui.drag", json!({"from": {"x": mx, "y": my}, "to": {"x": mx - 14.0, "y": my}, "steps": 6})); + d.frames(3); + let x2 = d.transition(c.id).unwrap(); + assert_eq!(x2["duration"].as_i64(), Some(d1), "sliding keeps the duration"); + let s2 = x2["start"].as_i64().unwrap(); + assert!(s2 < s1, "and moves it left: {s1} -> {s2}"); + assert!(s2 + d1 >= c.cut, "still over the cut"); + assert_eq!((s1 - s2) % frame, 0, "by whole frames"); + // two drags, two undo steps + d.exec("edit.undo", json!({})); + let back = d.transition(c.id).unwrap(); + assert_eq!((back["start"].as_i64(), back["duration"].as_i64()), (Some(s1), Some(d1)), "the slide is one step"); + d.exec("edit.undo", json!({})); + let back = d.transition(c.id).unwrap(); + assert_eq!((back["start"].as_i64(), back["duration"].as_i64()), (Some(c.start), Some(c.duration)), "the resize is the other"); + // the ruler: a click at its left end puts the playhead before the cut, at its right end after + let (x, y, w, h, _) = d.find("effectControls.transition.timeline.ruler").unwrap(); + d.ok("ui.click", json!({"x": x + 3.0, "y": y + h / 2.0})); + d.frames(3); + let ph = d.exec("sequence.inspect", json!({}))["playhead"].as_i64(); + assert!(ph.is_some_and(|p| p < c.cut), "{ph:?}"); + d.ok("ui.click", json!({"x": x + w - 3.0, "y": y + h / 2.0})); + d.frames(3); + let ph = d.exec("sequence.inspect", json!({}))["playhead"].as_i64(); + assert!(ph.is_some_and(|p| p > c.cut), "{ph:?}"); + assert!(d.find("effectControls.transition.timeline.playhead").is_some(), "the playhead shows in the transition's timeline"); +} diff --git a/docs/gaps.md b/docs/gaps.md index b479a6d1..09461410 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -141,7 +141,8 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** rearrangement (#493); clicking empty space to deselect (#683); filmstrip thumbnails (#614); marquee selection in the Project panel (#578); Freeform bins and middle-mouse pan (#579); Program monitor Button Editor (+) (#431); keyframe curves for Position (#448); transition - editing controls (#577); track renaming (#654); window controls on macOS (#637); UI scale on + editing controls (#577: Effect Controls has the transition's own timeline, drag to slide or resize; + the A/B previews with Start / End sliders remain); track renaming (#654); window controls on macOS (#637); UI scale on X11 (#457). - **Estimate:** 120–200 h. Doc: [ui-parity.md](ui-parity.md). diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 611bb827..75844919 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -24,7 +24,7 @@ open interaction reports (about 40 of the 188 open issues). | Panels and docking | 17 workspaces, dock / undock / float, drop zones | 11 workspaces, docking; rearranging windows reported missing (#493); multi-monitor floating windows (#466) | 60% | #493, #466 | 20–35 h | | Project panel | List / Icon / Freeform, hover scrub, marquee | all three views, hover scrub, view presets; no marquee (#578), Freeform bins and pan (#579), bins can't be left on Windows (#587), duplicate imports allowed (#356) | 75% | #578, #579, #587, #456 | 10–20 h | | Timeline display | filmstrip thumbnails, waveforms, track heights, renaming | clip thumbnails, waveforms, heights; no continuous filmstrip (#614), no track rename (#654), wheel scroll in audio area (#400) | 70% | #614, #654, #400 | 8–15 h | -| Transitions | drag, resize, alignment, edit in Effect Controls | drag / select / resize (#503); transition controls (#577) | 70% | #577 | 5–10 h | +| Transitions | drag, resize, alignment, edit in Effect Controls | drag / select / resize (#503); settings and the transition's own timeline in Effect Controls, drag to slide or resize (#577); no A/B previews with Start / End sliders | 70% | #577 | 5–10 h | | Numeric entry and scrubbing | scrubbable hot text, typed values | scrubbable values, timecode scrub-edit (#576); audio clip mixer values cannot be typed (#481) | 80% | #481 | 3–6 h | | Drag and drop | OS files into Project / Timeline, clipboard paste | OS drops into the timeline (#517); no clipboard paste of images / files (#611) | 70% | #519, #611 | 5–10 h | | Theme and appearance | dark / light, brightness | dark / light / system (#505, #555) | 85% | macOS window controls (#637), Wayland title bar (#433), X11 scale (#457) | 5–10 h | From 73f9982c0c781d92486c34230c8245a2c5edf146 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 00:22:08 +0200 Subject: [PATCH 134/144] #498: French (fr) interface translation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: French, one of Premiere's interface languages and one of the 12 key languages in docs/gaps.md G14, had no catalog; #498 asked for it. Fix: a new fr.tsv catalog with every entry Spanish has (menus, panels, dialogs, Settings pages, the Effects / Effect Controls registry, the Keyboard Shortcuts dialog, status messages) plus the native macOS Settings item, written from the plain meaning of the English text with ordinary French editing vocabulary (infinitive commands, "vous", French sentence case and spacing). Language::Fr is persisted as `fr`; app.language.french joins Edit > Language (checkmark, native macOS menu), Settings > General offers Français, and System Language picks French for any fr-* locale. Like Japanese, tests require only the core menus, so later strings show in English until translated; a French entry without a Spanish one fails. A new test checks the bundled fonts cover every French letter (the glyph check is shared with Ukrainian). Docs: G14, localization-parity, ROADMAP language row, ui-egui README. Co-Authored-By: Claude Opus 5.5 --- ATTRIBUTION.md | 1 + ROADMAP.md | 2 +- apps/filmcraft/src/native_menu.rs | 2 + crates/engine/src/settings.rs | 3 +- crates/engine/src/settings_tests.rs | 4 +- crates/ui-egui/README.md | 4 +- crates/ui-egui/src/i18n/fr.tsv | 3544 ++++++++++++++++++++ crates/ui-egui/src/i18n/fr.tsv.attribution | 8 + crates/ui-egui/src/i18n/mod.rs | 113 +- crates/ui-egui/src/menus.rs | 4 + docs/gaps.md | 6 +- docs/localization-parity.md | 13 +- 12 files changed, 3672 insertions(+), 32 deletions(-) create mode 100644 crates/ui-egui/src/i18n/fr.tsv create mode 100644 crates/ui-egui/src/i18n/fr.tsv.attribution diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index ce7bd609..0c1793ed 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -9,6 +9,7 @@ iconography, images or other Adobe assets; open licences only; every asset attri | Asset | Author | Source | Licence | |---|---|---|---| +| `crates/ui-egui/src/i18n/fr.tsv` | FilmCraft contributors | Original work: clean-room French interface translations | MIT OR Apache-2.0 | | `crates/ui-egui/src/i18n/ru.tsv` | FilmCraft contributors | Original work: clean-room Russian interface translations | MIT OR Apache-2.0 | | `crates/ui-egui/src/i18n/uk.tsv` | FilmCraft contributors | Original work: clean-room Ukrainian interface translations | MIT OR Apache-2.0 | | `assets/fonts/Inter-Regular.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) | diff --git a/ROADMAP.md b/ROADMAP.md index 4c5c2be9..9566bc59 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -78,7 +78,7 @@ Premiere ships 10 interface languages. Measured from our catalogs (3,498 strings | Spanish | partial (whole interface) | 99.9% | | Hindi | none (no Devanagari shaping in the interface) | 0% | | Arabic | none (no RTL interface) | 0% | -| French | none | 0% | +| French | partial (whole interface; native review welcome) | ~100% | | Portuguese (Brazil) | menus only | 9.9% | | Indonesian | none | 0% | | Japanese | partial (whole interface) | 99.7% | diff --git a/apps/filmcraft/src/native_menu.rs b/apps/filmcraft/src/native_menu.rs index 562575f2..61241a8a 100644 --- a/apps/filmcraft/src/native_menu.rs +++ b/apps/filmcraft/src/native_menu.rs @@ -107,6 +107,8 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver, Sho filmcraft_ui_egui::i18n::Language::De } else if checked("app.language.russian") { filmcraft_ui_egui::i18n::Language::Ru + } else if checked("app.language.french") { + filmcraft_ui_egui::i18n::Language::Fr } else { filmcraft_ui_egui::i18n::Language::En }; diff --git a/crates/engine/src/settings.rs b/crates/engine/src/settings.rs index 5a14d32c..e6f91d78 100644 --- a/crates/engine/src/settings.rs +++ b/crates/engine/src/settings.rs @@ -34,7 +34,7 @@ pub const PREFS_VERSION: u32 = 2; #[serde(rename_all = "camelCase", default)] pub struct GeneralPrefs { /// Interface language (persisted independently of projects): `en`, `ja`, `es`, `pt-br`, `uk`, - /// `zh-cn`, `de`, or `system` (the default: the operating system's preferred language when the + /// `zh-cn`, `de`, `ru`, `fr`, or `system` (the default: the operating system's preferred language when the /// interface has it, otherwise English). pub interface_language: String, /// "At Startup": `showHome` (FilmCraft: the demo project), `openMostRecent`, `emptyProject`. @@ -776,6 +776,7 @@ static CATEGORIES: &[Category] = &[ ("zh-cn", "简体中文"), ("de", "Deutsch"), ("ru", "Русский"), + ("fr", "Français"), ]), true, ), diff --git a/crates/engine/src/settings_tests.rs b/crates/engine/src/settings_tests.rs index fd442cef..6df9e246 100644 --- a/crates/engine/src/settings_tests.rs +++ b/crates/engine/src/settings_tests.rs @@ -29,13 +29,15 @@ fn interface_language_persists_and_rejects_hostile_values() { let mut s = Session { prefs_path: Some(path.clone()), ..Session::default() }; set(&mut s, "general.interfaceLanguage", json!("es")); assert_eq!(Preferences::load(&path).general.interface_language, "es"); - for value in [json!("xx"), json!("es-MX"), json!("de-DE"), json!(null), json!(-1), json!({}), json!("x".repeat(4096))] { + for value in [json!("xx"), json!("es-MX"), json!("de-DE"), json!("fr-FR"), json!(null), json!(-1), json!({}), json!("x".repeat(4096))] { assert!(s.execute("prefs.set", json!({"key": "general.interfaceLanguage", "value": value})).is_err()); assert_eq!(s.prefs.general.interface_language, "es"); } assert_eq!(Preferences::load(&path).general.interface_language, "es"); set(&mut s, "general.interfaceLanguage", json!("de")); assert_eq!(Preferences::load(&path).general.interface_language, "de"); + set(&mut s, "general.interfaceLanguage", json!("fr")); + assert_eq!(Preferences::load(&path).general.interface_language, "fr"); // back to following the operating system (#218) set(&mut s, "general.interfaceLanguage", json!("system")); assert_eq!(Preferences::load(&path).general.interface_language, "system"); diff --git a/crates/ui-egui/README.md b/crates/ui-egui/README.md index 1c723fea..69ccdc1e 100644 --- a/crates/ui-egui/README.md +++ b/crates/ui-egui/README.md @@ -18,7 +18,7 @@ the committed span and undo. Set `FILMCRAFT_UI_SNAPSHOT_DIR` to render screensho ## Localisation Edit > Language offers English, Japanese, Spanish, Brazilian Portuguese, Ukrainian, Simplified -Chinese and German. The language is stored in the engine's `general.interfaceLanguage` preference, +Chinese, German, Russian and French. The language is stored in the engine's `general.interfaceLanguage` preference, restored on startup, and also available in Settings > General. Its default, System Language (`system`), follows the operating system: the first of the user's preferred languages that the interface has (the host supplies them through @@ -32,7 +32,7 @@ fills translated templates, and `i18n::t` translates names from registries. Plac (including user filenames containing braces) are inserted literally. Catalog translations are original work using ordinary language, without proprietary localisation resources. -Spanish and Japanese cover menus, panels, dialogs, settings and registry labels. Searches accept both the +Spanish, Japanese, Simplified Chinese and French cover menus, panels, dialogs, settings and registry labels. Searches accept both the translated label and its English source, including Unicode capitals. Project content, command ids and preference values retain their original values. Engine errors, CLI and MCP messages remain English. Brazilian Portuguese and Ukrainian currently cover the core menus, German every menu; they diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv new file mode 100644 index 00000000..f9f8c660 --- /dev/null +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -0,0 +1,3544 @@ +# French (fr) catalog. Same format as `es.tsv`: context source translation. +# +# Clean-room: every entry was written from the plain meaning of the English text using ordinary +# French video-editing vocabulary. No proprietary localisation resource, string table or product UI +# was consulted or copied; add entries the same way. Commands use the infinitive ("Enregistrer +# sous…"), messages address the user as "vous", and labels use French sentence case. Product, +# format and technology names (FilmCraft, ArtCraft, Lumetri, ProRes, LUFS, EDL, AAF…) keep their +# spelling. French spacing: a space before ":", ";", "?" and "!". This catalog has the same entries +# as `es.tsv` plus the native macOS Settings menu; strings added later show in English until they +# are translated. A native-speaker review is welcome. +# +# Terms: clip = clip, track = piste, sequence = séquence, timeline = timeline, bin = chutier, +# playhead = tête de lecture, In/Out = point d'entrée / de sortie, marker = marqueur, keyframe = +# image clé, frame hold = image figée, edit point = point de montage, panel = panneau, workspace = +# espace de travail, render = rendu, preset = préréglage, captions = sous-titres, ripple = avec +# décalage, overwrite = écraser, scopes = oscilloscopes, preferences (also the native macOS +# Settings menu) = Préférences, settings = paramètres. + + About FilmCraft À propos de FilmCraft + Add Audio Submix Track Ajouter une piste de sous-mixage audio + Add Caption at Playhead Ajouter un sous-titre à la tête de lecture + Add Chapter Marker… Ajouter un marqueur de chapitre… + Add Edit Ajouter un point de montage + Add Edit to All Tracks Ajouter un point de montage à toutes les pistes + Add Flash Cue Marker… Ajouter un marqueur de point de repère Flash… + Add Frame Hold Ajouter une image figée + Add Guide… Ajouter un repère… + Add Marker Ajouter un marqueur + Add New Caption Track… Ajouter une piste de sous-titres… + Add Range Marker Ajouter un marqueur de plage + Add Range Marker to In and Out Ajouter un marqueur de plage de l'entrée à la sortie + Add Tracks… Ajouter des pistes… + Adjustment Layer… Calque d'effets… + Align to Selection Aligner sur la sélection + Align to Video Frame Aligner sur l'image vidéo + Align to Video Frame as Group Aligner sur l'image vidéo en tant que groupe + All Panels Tous les panneaux + Alpha Alpha + Appearance Apparence + Appearance… Apparence… + Apply Audio Transition Appliquer une transition audio + Apply Default Transitions to Selection Appliquer les transitions par défaut à la sélection + Apply Video Transition Appliquer une transition vidéo + Arrange Disposition + ArtCraft Website Site web d'ArtCraft + Assembly Assemblage + Attach Proxies… Associer des proxys… + Audio Channels… Canaux audio… + Audio Clip Mixer Mixeur de clips audio + Audio Gain… Gain audio… + Audio Hardware… Matériel audio… + Audio In Entrée audio + Audio Meters Vumètres audio + Audio Options Options audio + Audio Out Sortie audio + Audio Track Mixer Mixeur de pistes audio + Audio Waveform Forme d'onde audio + Auto Save… Enregistrement automatique… + Automate to Sequence… Automatiser vers la séquence… + Bars and Tone… Barres et tonalité… + Bin Chutier + Bin From Selection Chutier à partir de la sélection + Black Video… Vidéo noire… + Blue Bleu + Bottom Bas + Breakout to Mono Séparer en mono + Bring Forward Avancer + Bring to Front Mettre au premier plan + Brown Marron + Captions Sous-titres + Captions and Graphics Sous-titres et graphiques + Captions… Sous-titres… + Caribbean Caraïbes + Center Horizontally Centrer horizontalement + Center Vertically Centrer verticalement + Cerulean Céruléen + Clear Effacer + Clear Guides Effacer les repères + Clear In Effacer l'entrée + Clear In and Out Effacer l'entrée et la sortie + Clear Markers Effacer les marqueurs + Clear Out Effacer la sortie + Clear Selected Marker Effacer le marqueur sélectionné + Close Fermer + Close All Other Projects Fermer tous les autres projets + Close All Projects Fermer tous les projets + Close Gap Combler l'espace + Close Project Fermer le projet + Color Management… Gestion des couleurs… + Color Matte… Cache couleur… + Comparison View Vue de comparaison + Composite Video Vidéo composite + Consolidate Duplicates Regrouper les doublons + Copy Copier + Copy Paste Includes Sequence Markers Copier-coller inclut les marqueurs de séquence + Create Captions from Transcript… Créer des sous-titres à partir de la transcription… + Create Multi-Camera Source Sequence… Créer une séquence source multicaméra… + Create Proxies… Créer des proxys… + Cut Couper + Darkest Le plus sombre + Delete Render Files Supprimer les fichiers de rendu + Delete Render Files In to Out Supprimer les fichiers de rendu de l'entrée à la sortie + Delete Tracks… Supprimer des pistes… + Delete Transcript Supprimer la transcription + Demo Footage Rushes de démonstration + Demo Project Projet de démonstration + Deselect All Tout désélectionner + Detach Proxies Dissocier les proxys + Display Mode Mode d'affichage + Distribute Répartir + Distribute Horizontally Répartir horizontalement + Distribute Space Horizontally Répartir l'espacement horizontalement + Distribute Space Vertically Répartir l'espacement verticalement + Distribute Vertically Répartir verticalement + Duplicate Dupliquer + Dynamic Audio Waveforms Formes d'onde audio dynamiques + Edit Édition + Edit Offline… Modifier hors ligne… + Edit Original Modifier l'original + Edit Subclip… Modifier le sous-clip… + Editing Montage + Effect Controls Paramètres d'effet + Effects Effets + Ellipse Ellipse + Enable Activer + Enable Remix Activer le remix + Essential Graphics Graphiques essentiels + Essential Sound Son essentiel + Events Événements + Export Exporter + Export As Motion Graphics Template… Exporter en tant que modèle d'animation graphique… + Extract Extraire + Extract Audio Extraire l'audio + Field Options… Options de trame… + File Fichier + File… Fichier… + Fill frame Remplir l'image + FilmCraft Help… Aide de FilmCraft… + FilmCraft on GitHub FilmCraft sur GitHub + FilmCraft on getartcraft.com FilmCraft sur getartcraft.com + Find Next Rechercher le suivant + Find… Rechercher… + Fit Adapter + Fit to frame Adapter à l'image + Flatten Aplatir + Forest Forêt + Frame Blending Fusion d'images + Frame Hold Options… Options d'image figée… + Frame Sampling Échantillonnage d'images + From Bin Depuis le chutier + From Source Monitor Depuis le moniteur source + From Source Monitor, Match Frame Depuis le moniteur source, faire correspondre l'image + From file… Depuis un fichier… + Full Intégrale + Generate Audio Waveform Générer la forme d'onde audio + Get Media File Properties for Obtenir les propriétés du fichier média pour + Go to Gap Atteindre l'espace vide + Go to In Atteindre l'entrée + Go to Next Caption Segment Atteindre le segment de sous-titre suivant + Go to Next Marker Atteindre le marqueur suivant + Go to Out Atteindre la sortie + Go to Previous Caption Segment Atteindre le segment de sous-titre précédent + Go to Previous Marker Atteindre le marqueur précédent + Go to Split Atteindre la division + Graphics and Titles Graphiques et titres + Graphics… Graphiques… + Green Vert + Group Grouper + Guide Templates Modèles de repères + Help Aide + Hide All Caption Tracks Masquer toutes les pistes de sous-titres + High Quality Playback Lecture haute qualité + History Historique + Import From Importer depuis + Import Image Sequence… Importer une séquence d'images… + Import from Media Browser Importer depuis l'Explorateur de médias + Import… Importer… + Info Informations + Ingest Settings… Paramètres d'ingestion… + Insert Insérer + Insert Frame Hold Segment Insérer un segment d'image figée + Install Motion Graphics Template… Installer un modèle d'animation graphique… + Interpret Footage… Interpréter le métrage… + Join the ArtCraft Discord… Rejoindre le Discord d'ArtCraft… + Keyboard Shortcuts… Raccourcis clavier… + Label Étiquette + Labels… Étiquettes… + Language Langue + Lavender Lavande + Learning Apprentissage + Left Gauche + Libraries Bibliothèques + Lift Soulever + Light Clair + Link Lier + Link Media… Lier le média… + Refresh Changed Media Actualiser les médias modifiés + Linked Selection Sélection liée + Lock Guides Verrouiller les repères + Lumetri Color Couleur Lumetri + Lumetri Scopes Oscilloscopes Lumetri + Magnification Agrandissement + Make Offline… Rendre hors ligne… + Make Subclip… Créer un sous-clip… + Make Subsequence Créer une sous-séquence + Manage Guides… Gérer les repères… + Mark Clip Marquer le clip + Mark In Marquer l'entrée + Mark Out Marquer la sortie + Mark Selection Marquer la sélection + Mark Split Marquer la division + Markers Marqueurs + Match Frame Faire correspondre l'image + Media Analysis & Transcription… Analyse et transcription des médias… + Media Browser Explorateur de médias + Media Cache… Cache des médias… + Media… Média… + Medium Moyenne + Memory… Mémoire… + Merge Clips… Fusionner des clips… + Metadata Métadonnées + Modify Modifier + Motion Graphics Template… Modèle d'animation graphique… + Multi-Camera Multicaméra + Multi-Camera View Vue multicaméra + Nest… Imbriquer… + New Nouveau + New Layer Nouveau calque + New Project Panel Nouveau panneau Projet + Next in Sequence Suivant dans la séquence + Next in Track Suivant dans la piste + Normalize Mix Track… Normaliser la piste de mixage… + Offline File… Fichier hors ligne… + Open Project… Ouvrir un projet… + Optical Flow Flux optique + Overwrite Écraser + Paste Coller + Paste Attributes… Coller les attributs… + Paste Insert Coller en insertion + Paused Resolution Résolution en pause + Playback Resolution Résolution de lecture + Playback… Lecture… + Plugins… Plug-ins… + Polygon Polygone + Preferences Préférences + Previous in Sequence Précédent dans la séquence + Previous in Track Précédent dans la piste + Program Programme + Progress Progression + Project Projet + Project Manager… Gestionnaire de projets… + Project Settings Paramètres du projet + Project… Projet… + Properties Propriétés + Purple Violet + Reconnect Full Resolution Media… Reconnecter les médias en pleine résolution… + Recover Unsaved Changes… Récupérer les modifications non enregistrées… + Rectangle Rectangle + Red Rouge + Redo Rétablir + Reference Monitor Moniteur de référence + Remix Remix + Remix Properties… Propriétés du remix… + Remove Attributes… Supprimer les attributs… + Remove Filler Words Supprimer les mots parasites + Remove Pauses Supprimer les pauses + Remove Unused Supprimer les éléments inutilisés + Rename… Renommer… + Render Audio Effectuer le rendu audio + Render Effects In to Out Rendu des effets de l'entrée à la sortie + Render In to Out Rendu de l'entrée à la sortie + Render Selection Effectuer le rendu de la sélection + Replace Fonts in Projects… Remplacer les polices dans les projets… + Replace With Clip Remplacer par le clip + Report an Issue… Signaler un problème… + Reset All Parameters Réinitialiser tous les paramètres + Reset Duration Réinitialiser la durée + Reset to Saved Layout Réinitialiser la disposition enregistrée + Restore Captions from Source Clip Restaurer les sous-titres du clip source + Reveal Log Files… Afficher les fichiers journaux… + Reverse Match Frame Correspondance d'image inverse + Revert Revenir à la version enregistrée + Revert Remix Annuler le remix + Review Révision + Right Droite + Ripple Delete Supprimer avec décalage + Ripple Sequence Markers Décaler les marqueurs de séquence + Rose Rose + Safe Margins Marges de sécurité + Save Enregistrer + Save All Tout enregistrer + Save As… Enregistrer sous… + Save Guides as Template… Enregistrer les repères comme modèle… + Save a Copy… Enregistrer une copie… + Save as Template… Enregistrer comme modèle… + Scale to Frame Size Adapter à la taille de l'image + Scene Edit Detection… Détection des changements de scène… + Scratch Disks… Disques de travail… + Search Bin Chutier de recherche + Select Sélectionner + Select All Tout sélectionner + Select All Matching Sélectionner toutes les correspondances + Select Label Group Sélectionner le groupe d'étiquettes + Select Next Graphic Sélectionner le graphique suivant + Select Next Layer Sélectionner le calque suivant + Select Previous Graphic Sélectionner le graphique précédent + Select Previous Layer Sélectionner le calque précédent + Selection Follows Playhead La sélection suit la tête de lecture + Selection as FilmCraft Project… Sélection en tant que projet FilmCraft… + Selection… Sélection… + Send Backward Reculer + Send to Back Mettre à l'arrière-plan + Sequence Séquence + Sequence From Clip Séquence à partir du clip + Sequence Settings… Paramètres de séquence… + Sequence… Séquence… + Show Active Caption Tracks Only Afficher uniquement les pistes de sous-titres actives + Show All Caption Tracks Afficher toutes les pistes de sous-titres + Show All Marker Colors Afficher toutes les couleurs de marqueur + Show Guides Afficher les repères + Show Rulers Afficher les règles + Show Through Edits Afficher les points de montage traversants + Simplify Sequence… Simplifier la séquence… + Snap in Program Monitor Magnétisme dans le moniteur programme + Snap in Timeline Magnétisme dans la timeline + Source Source + Source Settings… Paramètres de la source… + Speed/Duration… Vitesse/durée… + Synchronize… Synchroniser… + System Compatibility Report… Rapport de compatibilité système… + Tan Beige + Teal Bleu canard + Text Texte + Time Interpolation Interpolation temporelle + Timecode Timecode + Timecode… Timecode… + Timeline Timeline + Timeline… Timeline… + Toggle Proxies Activer/désactiver les proxys + Tools Outils + Top Haut + Transcribe Sequence… Transcrire la séquence… + Transcribe… Transcrire… + Transcript Transcription + Transparent Video… Vidéo transparente… + Trim Edit Rogner le point de montage + Trim… Rogner… + Undo Annuler + Ungroup Dissocier le groupe + Universal Counting Leader… Amorce de décompte universelle… + Update Metadata… Mettre à jour les métadonnées… + Upgrade Caption to Graphic Convertir le sous-titre en graphique + Upgrade to Source Graphic Convertir en graphique source + Vertical Text Texte vertical + Video In Entrée vidéo + Video Options Options vidéo + Video Out Sortie vidéo + Video and Audio Waveform Split Vidéo et forme d'onde audio séparées + View Affichage + Violet Violet + Window Fenêtre + Workspaces Espaces de travail + Yellow Jaune + Zoom In Zoom avant + Zoom Out Zoom arrière + Zoom to Sequence Zoom sur la séquence + Home Accueil + Import Importer + {name} - Edited {name} - Modifié + Full screen Plein écran + Volume Volume + Master volume: use the Audio Track Mixer Volume principal : utilisez le Mixeur de pistes audio + Search Rechercher + Quick Export Exportation rapide + Join the ArtCraft Discord (discord.gg/artcraft) Rejoindre le Discord d'ArtCraft (discord.gg/artcraft) + Cancel Annuler + OK OK + Interpret Footage Interpréter le métrage + Color Management · {n} item Gestion des couleurs · {n} élément + Color Management · {n} items Gestion des couleurs · {n} éléments + Media colour space (from file metadata): {detected} Espace colorimétrique du média (d'après les métadonnées du fichier) : {detected} + Override media colour space: Remplacer l'espace colorimétrique du média : + Use file metadata Utiliser les métadonnées du fichier + Sequence Color Management Gestion des couleurs de la séquence + Working Color Space Espace colorimétrique de travail + Wide gamut color (composite in BT.2020) Couleurs à gamut étendu (composition en BT.2020) + Auto tone map media (HDR and log into SDR) Mappage automatique des tons des médias (HDR et log vers SDR) + Audio Gain Gain audio + Set Gain to: Définir le gain sur : + Adjust Gain by: Ajuster le gain de : + Normalize Max Peak to: Normaliser le pic maximal à : + Normalize All Peaks to: Normaliser tous les pics à : + Peak Amplitude: {peak} Amplitude de crête : {peak} + Standard Standard + Adaptive Adaptatif + Mono Mono + Stereo Stéréo + Add Tracks Ajouter des pistes + Amount Nombre + Placement Emplacement + Track type Type de piste + Add video tracks Ajouter des pistes vidéo + Add audio tracks Ajouter des pistes audio + Add audio submix tracks Ajouter des pistes de sous-mixage audio + After {name} Après {name} + Before First Track Avant la première piste + Delete Tracks Supprimer des pistes + Video Tracks Pistes vidéo + Audio Tracks Pistes audio + Delete Video Tracks Supprimer des pistes vidéo + Delete Audio Tracks Supprimer des pistes audio + All Empty Tracks Toutes les pistes vides + Caption Tracks Pistes de sous-titres + Delete Caption Tracks Supprimer des pistes de sous-titres + Delete Caption Track Supprimer la piste de sous-titres + Delete Empty Caption Tracks Supprimer les pistes de sous-titres vides + Version {version} Version {version} + A clean-room, pure-Rust non-linear video editor. Part of the ArtCraft family. Un éditeur vidéo non linéaire en pur Rust, conçu en salle blanche. Membre de la famille ArtCraft. + Join the ArtCraft Discord Rejoindre le Discord d'ArtCraft + Source code on GitHub Code source sur GitHub + Report an issue Signaler un problème + MIT OR Apache-2.0. Fonts: Inter, Noto Serif and JetBrains Mono (SIL OFL 1.1). MIT OR Apache-2.0. Polices : Inter, Noto Serif et JetBrains Mono (SIL OFL 1.1). + just now à l'instant + {n} s ago il y a {n} s + {n} min ago il y a {n} min + {n} h ago il y a {n} h + All Events Tous les événements + Warnings and Errors Avertissements et erreurs + Errors Only Erreurs uniquement + Clear All Tout effacer + No events. Aucun événement. + Show finished jobs Afficher les tâches terminées + Cancelled Annulée + Failed: {e} Échec : {e} + Done Terminée + No background jobs. Aucune tâche en arrière-plan. + Recover Unsaved Changes Récupérer les modifications non enregistrées + Untitled Sans titre + FilmCraft was closed while “{name}” had unsaved changes. FilmCraft a été fermé alors que « {name} » contenait des modifications non enregistrées. + FilmCraft quit unexpectedly while “{name}” had unsaved changes. FilmCraft s'est fermé de manière inattendue alors que « {name} » contenait des modifications non enregistrées. + Recover unsaved changes from {when}? Récupérer les modifications non enregistrées de {when} ? + The project had not been saved yet. Le projet n'avait pas encore été enregistré. + {n} sessions have unsaved changes: {n} sessions contiennent des modifications non enregistrées : + Discard Ignorer + Recover Récupérer + Not Now Pas maintenant + Are you sure you want to discard your changes to '{file}'? Voulez-vous vraiment ignorer vos modifications de « {file} » ? + Yes Oui + No Non + Add media to your project. Drop files anywhere in the window, or choose a source below. Ajoutez des médias à votre projet. Déposez des fichiers n'importe où dans la fenêtre ou choisissez une source ci-dessous. + Browse files… Parcourir les fichiers… + Movies, audio and stills from disk Films, audio et images fixes depuis le disque + Demo footage Rushes de démonstration + Six procedural 1080p clips with sound Six clips procéduraux 1080p avec son + Demo project Projet de démonstration + A cut sequence with transitions, effects and music Une séquence montée avec transitions, effets et musique + Bars and Tone Barres et tonalité + SMPTE HD bars with 1 kHz reference tone Barres SMPTE HD avec tonalité de référence à 1 kHz + Community Communauté + Join us on Discord Rejoignez-nous sur Discord + OMF Export Settings Paramètres d'exportation OMF + AAF Export Settings Paramètres d'exportation AAF + OMF Title: Titre OMF : + Mixdown video Mixage final de la vidéo + Audio: Audio : + Encapsulate Encapsuler + Separate Audio Audio séparé + Embed Incorporer + Separate Files Fichiers séparés + Link to Media Lien vers les médias + Audio File Format: Format de fichier audio : + Sample Rate: Fréquence d'échantillonnage : + Bits per Sample: Bits par échantillon : + Rendering: Rendu : + Trim audio files Rogner les fichiers audio + Handle Frames: Images de marge : + Render audio clip effects Effectuer le rendu des effets de clips audio + Breakout to mono Séparer en mono + Exported {path} Exporté : {path} + Select a clip to view its metadata. Sélectionnez un clip pour afficher ses métadonnées. + Clip: {name} Clip : {name} + Open Ouvrir + (no sequence) (aucune séquence) + Show or hide {color} markers Afficher ou masquer les marqueurs {color} + Marker Marqueur + {tc} Chapter {tc} Chapitre + No markers. Press M to add one. Aucun marqueur. Appuyez sur M pour en ajouter un. + All markers are hidden by the colour filter. Tous les marqueurs sont masqués par le filtre de couleur. + Type: Type : + Video: Vidéo : + Codec: Codec : + Duration: Durée : + Settings: Paramètres : + Playhead: Tête de lecture : + Video {n}: Vidéo {n} : + Audio {n}: Audio {n} : + UI: Interface : + {fps} fps · {n} frames queued {fps} fps · {n} images en file d'attente + {panel} — coming in a later milestone {panel} — disponible dans un jalon ultérieur + Close Panel Fermer le panneau + Close Other Timeline Panels Fermer les autres panneaux Timeline + Close All Tout fermer + Maximize Frame Agrandir le cadre + Restore Frame Restaurer le cadre + Restore Workspace Restaurer l'espace de travail + Reveal Sequence in Project Afficher la séquence dans le projet + Video Thumbnails Vignettes vidéo + Audio Waveforms Formes d'onde audio + Delete Preset Supprimer le préréglage + Export Preset… Exporter le préréglage… + Import Presets… Importer des préréglages… + Effect presets Préréglages d'effets + Save Preset Enregistrer le préréglage + Name: Nom : + Type Type + Scale Échelle + Anchor to In Point Ancrer au point d'entrée + Anchor to Out Point Ancrer au point de sortie + Without keyframes Sans images clés + Description: Description : + Saved preset “{name}” Préréglage « {name} » enregistré + Current duration {seconds} s Durée actuelle : {seconds} s + Remix Properties Propriétés du remix + Target Duration: Durée cible : + Segments: Segments : + Fewer · More Moins · Plus + Variations: Variantes : + Remixed to {seconds} s Remixé à {seconds} s + {mode} · no clip {mode} · aucun clip + {mode} · no sequence {mode} · aucune séquence + Remove Row Supprimer la ligne + Add a timecode row Ajouter une ligne de timecode + Panner Panoramique + Mute Couper le son + Send {n} Level Niveau du départ {n} + Show Keyframes Afficher les images clés + Clip Keyframes Images clés du clip + Track Keyframes Images clés de la piste + Delete Supprimer + Untitled Workspace Espace de travail sans titre + New Workspace Nouvel espace de travail + Edit Workspaces Modifier les espaces de travail + Rename Renommer + Restore Original Restaurer l'original + Search effects Rechercher des effets + Apply to Selected Clips Appliquer aux clips sélectionnés + Metadata Display Affichage des métadonnées + Save As New View Preset Enregistrer comme nouveau préréglage d'affichage + Manage Saved View Presets Gérer les préréglages d'affichage enregistrés + Freeform View Options Options de la vue libre + Save Arrangement Enregistrer la disposition + Saves the view, columns, sort, thumbnail and font size. Enregistre la vue, les colonnes, le tri, la vignette et la taille de police. + Grid spacing: Espacement de la grille : + Default clip size: Taille de clip par défaut : + Snap to grid Magnétisme sur la grille + Show clip names Afficher les noms des clips + Show durations Afficher les durées + Show: Afficher : + Search fields Rechercher dans les champs + Project Metadata Métadonnées du projet + Add Property… Ajouter une propriété… + Column order Ordre des colonnes + {n}. (empty) {n}. (vide) + Restore Restaurer + Overwrite with Current View Remplacer par la vue actuelle + Graphics text search arrives with M10.1–M10.2 La recherche de texte dans les graphiques arrive avec M10.1–M10.2 + Open a sequence to work with captions Ouvrez une séquence pour travailler sur les sous-titres + Add captions Ajouter des sous-titres + Create a caption track or import a caption file. Créez une piste de sous-titres ou importez un fichier de sous-titres. + Create new caption track Créer une piste de sous-titres + Import captions file… Importer un fichier de sous-titres… + New {format} track Nouvelle piste {format} + Add caption at playhead Ajouter un sous-titre à la tête de lecture + Split caption at playhead Scinder le sous-titre à la tête de lecture + Merge selected captions Fusionner les sous-titres sélectionnés + Delete selected captions Supprimer les sous-titres sélectionnés + No captions on this track. Press + to add one at the playhead. Aucun sous-titre sur cette piste. Appuyez sur + pour en ajouter un à la tête de lecture. + No matching captions. Aucun sous-titre correspondant. + Open a sequence to see its transcript Ouvrez une séquence pour voir sa transcription + Transcribe sequence Transcrire la séquence + Speech-to-text turns the dialogue into editable text. La reconnaissance vocale convertit les dialogues en texte modifiable. + This build has no speech-to-text; import a transcript with transcript.set. Cette version n'inclut pas la reconnaissance vocale ; importez une transcription avec transcript.set. + Transcribe Transcrire + Extract selected text Extraire le texte sélectionné + Lift selected text Soulever le texte sélectionné + Remove filler words Supprimer les mots parasites + Create captions Créer des sous-titres + Speaker Intervenant + Box Cadre + Align Aligner + Align left Aligner à gauche + Align center Centrer + Align right Aligner à droite + Position Position + Position: top Position : haut + Position: middle Position : milieu + Position: bottom Position : bas + (gap) (espace vide) + Out: {name} Sortie : {name} + In: {name} Entrée : {name} + Play Around Edit (Space loops, Shift+K once) Lire autour du point de montage (Espace en boucle, Maj+K une fois) + Trim Backward Many Rogner de plusieurs images en arrière + Trim Backward Rogner en arrière + Apply Default Transitions Appliquer les transitions par défaut + Apply Default Transitions to Selection (Shift+D) Appliquer les transitions par défaut à la sélection (Maj+D) + Trim Forward Rogner en avant + Trim Forward Many Rogner de plusieurs images en avant + Exit Trim Mode Quitter le mode de rognage + Voice-over record (right-click: Voice-Over Record Settings…) Enregistrer une voix off (clic droit : Paramètres d'enregistrement de voix off…) + Voice-Over Record Settings… Paramètres d'enregistrement de voix off… + Recording voice-over… Enregistrement de la voix off… + Voice-over recorded: {p} Voix off enregistrée : {p} + Voice-over recording stopped (nothing recorded) Enregistrement de la voix off arrêté (rien n'a été enregistré) + Post-roll Post-roll + ● Recording ● Enregistrement + Voice-Over Record Settings Paramètres d'enregistrement de voix off + Source: Source : + Default Input Entrée par défaut + Input: Entrée : + Channel {n} Canal {n} + Countdown Sound Cues Bips de compte à rebours + Pre-roll: Pré-roll : + seconds secondes + Post-roll: Post-roll : + Playback starts the pre-roll before the playhead (or the In point); with In/Out marked, recording punches in and out there. La lecture démarre au pré-roll avant la tête de lecture (ou le point d'entrée) ; si l'entrée et la sortie sont définies, l'enregistrement démarre et s'arrête à ces points. + Effect Effet + Clip Fx Editor Éditeur d'effets de clip + Track Fx Editor Éditeur d'effets de piste + Master Gain Gain principal + Reset Réinitialiser + Band {n} Bande {n} + Solo Solo + Bypass Bypass + Thr Seuil + Ratio Ratio + Att Att + Rel Rel + Gain Gain + Output Gain Gain de sortie + Limiter Limiteur + Link Channels Lier les canaux + Limiter Threshold Seuil du limiteur + Limiter Release Relâchement du limiteur + Auto Gate Gate automatique + Threshold Seuil + Attack Attaque + Release Relâchement + Hold Maintien + Compressor Compresseur + Auto Makeup Compensation auto + Makeup Compensation + Expander Expandeur + Soft Clip Écrêtage doux + (no sequences) (aucune séquence) + (no clips) (aucun clip) + Show Transport Controls Afficher les commandes de transport + Loop Boucle + Multi-Camera Audio Follows Video L'audio multicaméra suit la vidéo + Multi-Camera Record Enregistrement multicaméra + Multi-Camera Selection Top Down Sélection multicaméra de haut en bas + Show Multi-Camera Preview Monitor Afficher le moniteur d'aperçu multicaméra + Auto-Adjust Multi-Camera Playback Quality Ajuster automatiquement la qualité de lecture multicaméra + Transmit Multi-Camera View Transmettre la vue multicaméra + Multi-Camera Layout Disposition multicaméra + Automatic Automatique + Edit Cameras… Modifier les caméras… + Add Marker (M) Ajouter un marqueur (M) + Mark In (I) Marquer l'entrée (I) + Mark Out (O) Marquer la sortie (O) + Go to In (Shift+I) Aller à l'entrée (Maj+I) + Step Back 1 Frame (Left) Reculer d'une image (Gauche) + Play-Stop Toggle (Space) Lecture/arrêt (Espace) + Step Forward 1 Frame (Right) Avancer d'une image (Droite) + Go to Out (Shift+O) Aller à la sortie (Maj+O) + Insert (,) Insérer (,) + Overwrite (.) Écraser (.) + Export Frame (Shift+E) Exporter l'image (Maj+E) + Lift (;) Soulever (;) + Extract (') Extraire (') + Export Frame: use Export mode (M6) Exporter l'image : utilisez le mode Exportation (M6) + Button Editor Éditeur de boutons + Multi-Camera Record on Enregistrement multicaméra activé + Multi-Camera Record off Enregistrement multicaméra désactivé + {first} - Merged {first} - Fusionné + {first} Multicam {first} Multicaméra + In Points Points d'entrée + Out Points Points de sortie + Clip Marker Marqueur de clip + Audio Audio + Merge Clips Fusionner les clips + Create Multi-Camera Source Sequence Créer une séquence source multicaméra + Synchronize Clips Synchroniser les clips + Clip Name: Nom du clip : + Sequence Name: Nom de la séquence : + {n} clip(s): {names} {n} clip(s) : {names} + {n} clip(s) selected; the clip on the reference track stays put. {n} clip(s) sélectionné(s) ; le clip de la piste de référence reste en place. + Synchronize Point Point de synchronisation + Ignore Hours Ignorer les heures + first marker premier marqueur + Offset Audio by: Décaler l'audio de : + Offset: Décalage : + frames images + Reference track: Piste de référence : + lowest (V1…) la plus basse (V1…) + Remove Audio from AV Clip Retirer l'audio du clip AV + Camera 1 Caméra 1 + All Cameras Toutes les caméras + Switch Audio Changer d'audio + Camera Names Noms des caméras + Clip Names Noms des clips + Enumerate Cameras Numéroter les caméras + Camera Angle Metadata Métadonnées d'angle de caméra + Move source clips to Processed Clips bin Déplacer les clips source dans le chutier Clips traités + Synchronized {n} clip group(s) {n} groupe(s) de clips synchronisé(s) + Merged clip created Clip fusionné créé + Multi-camera source sequence with {n} camera(s) Séquence source multicaméra avec {n} caméra(s) + No multi-camera clip at the playhead Aucun clip multicaméra à la tête de lecture + Camera {n} — {name} Caméra {n} — {name} + Page {page} of {pages} Page {page} sur {pages} + Previous page Page précédente + Next page Page suivante + Multi-Camera Record On/Off (0) Activer/désactiver l'enregistrement multicaméra (0) + Record Enregistrer + Edit Cameras Modifier les caméras + Cameras shown in the Multi-Camera view (uncheck to hide). Caméras affichées dans la vue multicaméra (décochez pour masquer). + Recorded {n} multi-camera cut(s) {n} coupe(s) multicaméra enregistrée(s) + Clamp Signal Limiter le signal + Presets Préréglages + Parade ({kind}) Parade ({kind}) + Waveform ({kind}) Forme d'onde ({kind}) + Parade Type Type de parade + Waveform Type Type de forme d'onde + Vectorscope Targets Cibles du vectorscope + Colour Space Espace colorimétrique + Brightness Luminosité + Signal Scale Échelle du signal + Histogram Histogramme + Paste Attributes Coller les attributs + Remove Attributes Supprimer les attributs + New Offline File Nouveau fichier hors ligne + Nested Sequence Name Nom de la séquence imbriquée + Make Subclip Créer un sous-clip + Edit Subclip Modifier le sous-clip + Modify Clip: Audio Channels Modifier le clip : canaux audio + Modify Clip: Timecode Modifier le clip : timecode + Frame Hold Options Options d'image figée + Field Options Options de trame + Save Project Enregistrer le projet + Motion Mouvement + Opacity Opacité + Time Remapping Remappage temporel + Channel Volume Volume du canal + In Point Point d'entrée + Out Point Point de sortie + Playhead Tête de lecture + Source Timecode Timecode source + Sequence Time Temps de la séquence + Video Attributes Attributs vidéo + Audio Attributes Attributs audio + No effects Aucun effet + Scale Attribute Times Adapter le minutage des attributs + File Name: Nom du fichier : + Tape Name: Nom de la bande : + Contains: Contient : + Video Vidéo + Width: Largeur : + Height: Hauteur : + Frame Rate: Fréquence d'images : + Media Start: Début du média : + Subclip: {name} Sous-clip : {name} + Start: Début : + End: Fin : + Restrict Trims To Subclip Boundaries Limiter le rognage aux limites du sous-clip + Convert to Master Clip Convertir en clip maître + Clip Channel Format: Format des canaux du clip : + Number of Audio Clips: Nombre de clips audio : + Media Source Channels Canaux source du média + Ch. {n} Can. {n} + Clip {n} Clip {n} + Source channels the selected audio clips play: Canaux source lus par les clips audio sélectionnés : + Set Timecode: Définir le timecode : + Use the file's timecode Utiliser le timecode du fichier + Hold On Figer sur + Timecode: Timecode : + Hold Filters Figer les filtres + Reverse Field Dominance Inverser la dominance de trame + Processing Options Options de traitement + None Aucun + Always Deinterlace Toujours désentrelacer + Flicker Removal Suppression du scintillement + Save changes to “{project_name}” before closing? Enregistrer les modifications de « {project_name} » avant de fermer ? + Don't Save Ne pas enregistrer + All media is online. Tous les médias sont en ligne. + {name} (copy) {name} (copie) + Offline Hors ligne + Missing Introuvable + Link Media Lier les médias + {n} clip(s) can't find their media. Locate them, search a folder, or leave them offline. {n} clip(s) ne trouvent pas leurs médias. Localisez-les, recherchez dans un dossier ou laissez-les hors ligne. + Clip Name Nom du clip + File Name Nom du fichier + File Path Chemin du fichier + Status État + Match file properties: Propriétés de fichier à faire correspondre : + File Extension Extension du fichier + Clip ID (fingerprint) ID du clip (empreinte) + Duration Durée + Media Start Début du média + Frame Size / Rate Taille/fréquence d'images + Align Timecode Aligner le timecode + Relink others automatically Relier les autres automatiquement + Search in: Rechercher dans : + Browse… Parcourir… + Exact name matches only Correspondances exactes du nom uniquement + No candidates yet — Search a folder or Locate the file. Aucun candidat pour l'instant — recherchez dans un dossier ou localisez le fichier. + ✔ same file ✔ même fichier + ✔ matches ✔ correspond + ✖ different file ✖ fichier différent + Preview Aperçu + Offline All Tout mettre hors ligne + Locate… Localiser… + Linked {n} clip(s) {n} clip(s) lié(s) + Nothing found with that name. Aucun élément trouvé avec ce nom. + Make Offline Mettre hors ligne + Make {n} selected clip(s) offline. Their clips show the offline slate until you link them again. Mettre hors ligne {n} clip(s) sélectionné(s). Ces clips affichent le carton hors ligne jusqu'à ce que vous les liiez à nouveau. + Media files remain on disk Les fichiers média restent sur le disque + Media files are deleted Les fichiers média sont supprimés + Create Proxies Créer des proxys + {n} clip(s) selected. {n} clip(s) sélectionné(s). + Format and size Format et taille + Destination Destination + Next to the original media, in a Proxies folder À côté des médias d'origine, dans un dossier Proxies + Proxies are made in the background and attached when done. Export always uses full-resolution media. Les proxys sont créés en arrière-plan et associés une fois terminés. L'exportation utilise toujours les médias en pleine résolution. + Creating {n} proxy file(s)… Création de {n} fichier(s) proxy… + Project Manager Gestionnaire de projets + Sequences Séquences + Resulting Project Projet résultant + Collect Files and Copy to New Location Rassembler les fichiers et copier vers un nouvel emplacement + Consolidate and Transcode Consolider et transcoder + Options Options + Exclude Unused Clips Exclure les clips inutilisés + Include Handles: Inclure les marges : + Include Proxies Inclure les proxys + Include Preview Files Inclure les fichiers d'aperçu + Destination Path Chemin de destination + Disk space: original {original} · resulting {result} ({n} files) Espace disque : original {original} · résultat {result} ({n} fichiers) + Disk space: — Espace disque : — + Calculate Calculer + Project Manager: writing {path} Gestionnaire de projets : écriture de {path} + Timeline: (no sequences) Timeline : (aucune séquence) + Browse Parcourir + Select audio clips, then click a preset to apply it. Sélectionnez des clips audio, puis cliquez sur un préréglage pour l'appliquer. + {name} (user) {name} (utilisateur) + Select audio clips to edit them here Sélectionnez des clips audio pour les modifier ici + Multiple Clips Selected Plusieurs clips sélectionnés + The selected clips have different audio types. Assign one type to edit them together. Les clips sélectionnés ont des types audio différents. Attribuez-leur un même type pour les modifier ensemble. + Assign an audio type to the selection: Attribuez un type audio à la sélection : + Clear Audio Type Effacer le type audio + Preset: Préréglage : + (Custom) (Personnalisé) + Preset name Nom du préréglage + Reduce Noise Réduire le bruit + Reduce Rumble Réduire les grondements + DeHum Supprimer le bourdonnement + DeEss Atténuer les sibilances + Reduce Reverb Réduire la réverbération + Dynamics Dynamique + EQ Égaliseur + Enhance Speech Améliorer la voix + Low Tone Graves + High Tone Aigus + Mix Mixage + Reverb Réverbération + Stereo Width Largeur stéréo + Remixing music to a target duration is not available yet. Trim the clip and add a crossfade instead. Le remixage de la musique à une durée cible n'est pas encore disponible. Rognez plutôt le clip et ajoutez un fondu enchaîné. + Pan Panoramique + Auto-Match Correspondance auto + Not matched yet Pas encore mis en correspondance + Matches each of the {n} clips on its own. Met en correspondance chacun des {n} clips séparément. + Duck against: Atténuer en fonction de : + Untagged Non classé + Sensitivity Sensibilité + Reduce By Réduire de + Fades Fondus + Generate Keyframes Générer des images clés + Clip Volume Volume du clip + (no clip selected) (aucun clip sélectionné) + Master · {name} Maître · {name} + Add Lumetri Color to clip Ajouter l'effet Couleur Lumetri au clip + Input LUT LUT d'entrée + Color Couleur + Temperature Température + Tint Nuance + Saturation Saturation + Exposure Exposition + Contrast Contraste + Highlights Tons clairs + Shadows Tons foncés + Whites Blancs + Blacks Noirs + HDR HDR + HDR White Blanc HDR + HDR Specular Spéculaire HDR + Look Look + Look LUT LUT de look + Intensity Intensité + Adjustments Réglages + Faded Film Film délavé + Sharpen Netteté + Vibrance Vibrance + RGB Curves Courbes RGB + HDR Range Plage HDR + Hue Saturation Curves Courbes de teinte et saturation + Hue vs Sat Teinte vs saturation + Hue vs Hue Teinte vs teinte + Hue vs Luma Teinte vs luminance + Luma vs Sat Luminance vs saturation + Sat vs Sat Saturation vs saturation + Midtones Tons moyens + Key Incrustation + Hue Teinte + Hue Range Plage de teinte + Saturation Min Saturation min. + Luma Min Luminance min. + Luma Max Luminance max. + Soften Adoucir + Show Mask Afficher le masque + Off Désactivé + Color/Gray Couleur/gris + Color/Black Couleur/noir + White/Black Blanc/noir + Refine Affiner + Denoise Débruiter + Blur Flou + Correction Correction + Hue Shift Décalage de teinte + Midpoint Point médian + Roundness Arrondi + Feather Contour progressif + LUT LUT + Color Match Correspondance des couleurs + Reference Référence + Face Detection (protect skin tones) Détection des visages (protéger les tons chair) + Apply Match Appliquer la correspondance + Create ellipse mask Créer un masque elliptique + Create 4-point polygon mask Créer un masque polygonal à 4 points + Free draw bezier Bézier à main levée + Click in the Program monitor to place mask points (drag for curves); click the first point or press Enter to close Cliquez dans le moniteur programme pour placer les points du masque (faites glisser pour créer des courbes) ; cliquez sur le premier point ou appuyez sur Entrée pour fermer + Delete Mask Supprimer le masque + Inverted Inversé + Drag: move · Alt-click: smooth/corner · Cmd-click: delete Glisser : déplacer · Alt+clic : lisse/angle · Cmd+clic : supprimer + Track selected mask backward Suivre le masque sélectionné en arrière + Track selected mask backward 1 frame Suivre le masque sélectionné d'une image en arrière + Track selected mask forward 1 frame Suivre le masque sélectionné d'une image en avant + Track selected mask forward Suivre le masque sélectionné en avant + Stop tracking Arrêter le suivi + Tracking method: {method} Méthode de suivi : {method} + Composite Composite + Comparison Comparaison + Video and Waveform Vidéo et forme d'onde + System Default Par défaut du système + Default location Emplacement par défaut + Font family Famille de polices + Label Colors Couleurs d'étiquette + Decoded frames held for the monitors and thumbnails: {used} of {budget}. Images décodées conservées pour les moniteurs et les vignettes : {used} sur {budget}. + FilmCraft keeps the rest of the RAM free for other applications. FilmCraft laisse le reste de la RAM libre pour les autres applications. + not available in this session non disponible dans cette session + Currently: {loc} — {files} file(s), {size} Actuellement : {loc} — {files} fichier(s), {size} + ({ch} output channels) ({ch} canaux de sortie) + Programme left → device output {l}, right → device output {r}{dev}. Outputs beyond the device's channels fall back to 1 and 2. Programme gauche → sortie {l} du périphérique, droite → sortie {r}{dev}. Les sorties au-delà des canaux du périphérique reviennent à 1 et 2. + Auto-saves go to: {dir} Enregistrements automatiques dans : {dir} + Recovery copy: {dir} Copie de récupération : {dir} + Last auto-save {a} · last recovery copy {j} Dernier enregistrement automatique {a} · dernière copie de récupération {j} + Last auto-save {a} Dernier enregistrement automatique {a} + Last recovery copy {j} Dernière copie de récupération {j} + Remove Media Cache Files Supprimer les fichiers du cache des médias + Deleted {n} file(s), {size}. {n} fichier(s) supprimé(s), {size}. + Reset… Réinitialiser… + Source · {name} Source · {name} + Reset Effect Réinitialiser l'effet + Reset Parameter Réinitialiser le paramètre + Save Preset… Enregistrer le préréglage… + Custom Setup Configuration personnalisée + Edit… Modifier… + Show graphs Afficher les graphiques + Toggle animation Activer/désactiver l'animation + Eyedropper (pick a color from the Program monitor) Pipette (prélever une couleur dans le moniteur programme) + Eyedropper: no picture to sample Pipette : aucune image à échantillonner + Add/Remove Keyframe Ajouter/supprimer une image clé + Temporal Interpolation Interpolation temporelle + Linear Linéaire + Bezier Bézier + Auto Bezier Bézier auto + Continuous Bezier Bézier continu + Ease In Lissage en entrée + Ease Out Lissage en sortie + Select a clip to grade Sélectionnez un clip à étalonner + Select a clip to see its properties Sélectionnez un clip pour voir ses propriétés + Anchor point Point d'ancrage + Rotation Rotation + Level Niveau + Speed {n}% Vitesse {n} % + Value Valeur + Velocity Vélocité + Default Par défaut + Custom ({n} points) Personnalisé ({n} points) + Select a graphic clip, or use the Type tool (T) on the Program monitor Sélectionnez un clip graphique ou utilisez l'outil Texte (T) dans le moniteur programme + Create new graphic Créer un graphique + New Text Layer Nouveau calque de texte + New Text Nouveau texte + New Rectangle Nouveau rectangle + New Ellipse Nouvelle ellipse + Delete Layer Supprimer le calque + Select a layer to edit its properties. Sélectionnez un calque pour modifier ses propriétés. + Align Left Aligner à gauche + Align Center Horizontally Centrer horizontalement + Align Right Aligner à droite + Align Top Aligner en haut + Align Center Vertically Centrer verticalement + Align Bottom Aligner en bas + Font Police + Font Style Style de police + Font Size Taille de police + Alignment Alignement + Left Align Text Aligner le texte à gauche + Center Align Text Centrer le texte + Right Align Text Aligner le texte à droite + Justify Justifier + Tracking Approche + Leading Interlignage + Baseline Shift Décalage de la ligne de base + Text Box Width Largeur de la zone de texte + Style Style + Faux Bold Faux gras + Faux Italic Faux italique + All Caps Tout en capitales + Small Caps Petites capitales + Underline Souligné + OpenType OpenType + Kerning Crénage + Ligatures Ligatures + Shape Forme + Size Taille + Corner Radius Rayon des angles + Polygon Sides Côtés du polygone + Fill Remplissage + Stroke Contour + Stroke 2 Contour 2 + Background Arrière-plan + Opacity Opacité + Size Taille + Corner Radius Rayon des angles + Shadow Ombre + Angle Angle + Distance Distance + Blur Flou + Search templates Rechercher des modèles + All Categories Toutes les catégories + Install… Installer… + Install Motion Graphics Template (.fcgt) Installer un modèle d'animation graphique (.fcgt) + No templates match. Aucun modèle ne correspond. + {category} · My Template {category} · Mon modèle + Apply Appliquer + Remove Retirer + {n} editable properties {n} propriétés modifiables + Template: {name} Modèle : {name} + Intro Duration Durée de l'introduction + Outro Duration Durée de la conclusion + Roll Défilement + Start Off Screen Commencer hors de l'écran + End Off Screen Terminer hors de l'écran + Preroll Pré-roll + Postroll Post-roll + Video Frame Cadre vidéo + Pin To Épingler à + Pinned Edges Bords épinglés + FilmCraft Graphics Template Modèle de graphiques FilmCraft + {n} Font Police de {n} + {n} Size Taille de {n} + {n} Color Couleur de {n} + {n} Position Position de {n} + Show {n} Afficher {n} + My Templates Mes modèles + Export As Motion Graphics Template Exporter en tant que modèle d'animation graphique + Category: Catégorie : + Editable properties: Propriétés modifiables : + Saved as a FilmCraft graphics template (.fcgt) in your templates folder. Enregistré en tant que modèle de graphiques FilmCraft (.fcgt) dans votre dossier de modèles. + Exported graphics template to {path} Modèle de graphiques exporté vers {path} + Replace Fonts in Projects Remplacer les polices dans les projets + Fonts used in the project: Polices utilisées dans le projet : + {f} (missing) {f} (manquante) + Replace: Remplacer : + Replaced {n} font uses {n} utilisations de polices remplacées + Show/Hide Effects and Sends Afficher/masquer les effets et les envois + Audio Track Mixer menu Menu du mixeur de pistes audio + Show/Hide Tracks… Afficher/masquer les pistes… + Meter Input(s) Only Mesurer uniquement les entrées + Show/Hide Tracks Afficher/masquer les pistes + Show All Tout afficher + Post-Fader Post-fader + Remove Effect Retirer l'effet + Fill the slots above first Remplissez d'abord les emplacements ci-dessus + pre pré + Pre-Fader Pré-fader + Mute Send Couper le son de l'envoi + Remove Send Retirer l'envoi + New Submix Nouveau sous-mixage + Pan / balance (drag; double-click: centre) Panoramique / balance (faites glisser ; double-clic : centre) + Solo safe Protection solo + Enable track for recording Activer la piste pour l'enregistrement + Volume (drag; double-click: 0 dB) Volume (faites glisser ; double-clic : 0 dB) + 5.1 panner (drag the puck; double-click: front centre) Panoramique 5.1 (faites glisser le point ; double-clic : centre avant) + Center Centre + Go to In Point Atteindre le point d'entrée + Go to Out Point Atteindre le point de sortie + Play In to Out Lire de l'entrée à la sortie + Record (voice-over on the record-enabled track) Enregistrer (voix off sur la piste activée pour l'enregistrement) + Write keyframes Écrire les images clés + Something went wrong while drawing the window. Your project is still open; save it to be safe. Une erreur s'est produite lors de l'affichage de la fenêtre. Votre projet est toujours ouvert ; enregistrez-le par précaution. + Continue Continuer + Go Back Précédent + Go Forward Suivant + Up One Level Remonter d'un niveau + File Types Displayed Types de fichiers affichés + List View Affichage en liste + Thumbnail View Affichage en vignettes + Ingest Ingestion + Open Ingest Settings Ouvrir les paramètres d'ingestion + Import as Image Sequence Importer en tant que séquence d'images + {sel} of {total} items selected {sel} éléments sélectionnés sur {total} + {total} items {total} éléments + Cannot open {dir}: {e} Impossible d'ouvrir {dir} : {e} + Favorites Favoris + Local Drives Disques locaux + Network Réseau + Recent Directories Dossiers récents + (none) (aucun) + Remove from Favorites Retirer des favoris + Add to Favorites Ajouter aux favoris + Clear Recent Directories Effacer les dossiers récents + Open In Source Monitor Ouvrir dans le moniteur source + Reveal in Finder Afficher dans le gestionnaire de fichiers + Edit Columns… Modifier les colonnes… + Hover Scrub Défilement au survol + New Media Browser Panel Nouveau panneau Explorateur de médias + Create Folder For Imported Projects Créer un dossier pour les projets importés + Allow Duplicate Media During Project Import Autoriser les médias en double lors de l'importation de projets + Refresh Actualiser + Edit Columns Modifier les colonnes + Guides Repères + (no audio) (aucun audio) + Generating waveform… Génération de la forme d'onde… + Reference {tc} Référence {tc} + Set Définir + Set the reference to the playhead Placer la référence sur la tête de lecture + Reference: next frame Référence : image suivante + Reference: previous frame Référence : image précédente + RGBA Channels Canaux RGBA + Add Guide Ajouter un repère + Save Guides as Template Enregistrer les repères comme modèle + Manage Guides Gérer les repères + Orientation: Orientation : + Vertical Vertical + Horizontal Horizontal + Position: Position : + No saved guide templates. Aucun modèle de repères enregistré. + Project: {name} Projet : {name} + Bin: {name} Chutier : {name} + Up one level Remonter d'un niveau + Video Usage: {vu} Audio Usage: {au} Utilisation vidéo : {vu} Utilisation audio : {au} + Icon View Affichage en icônes + Freeform View Vue libre + Sort Icons Trier les icônes + Clear (Delete) Effacer (Suppr) + New Item Nouvel élément + New Bin Nouveau chutier + Find Rechercher + Automate to Sequence Automatiser vers la séquence + Select items to clear Sélectionnez les éléments à effacer + User Order Ordre utilisateur + Descending Décroissant + Refresh Project Actualiser le projet + Reveal Project in Finder… Afficher le projet dans le gestionnaire de fichiers… + New Bin From Selection Nouveau chutier à partir de la sélection + New Search Bin Nouveau chutier de recherche + New Project Shortcut Nouveau raccourci de projet + List Liste + Icon Icône + Freeform Libre + Preview Area Zone d'aperçu + Thumbnails Vignettes + Thumbnails show effects applied Les vignettes affichent les effets appliqués + Thumbnail controls for all pointing devices Commandes de vignettes pour tous les périphériques de pointage + Refresh Sort Order Actualiser l'ordre de tri + Metadata Display… Affichage des métadonnées… + Save Current View Preset Enregistrer le préréglage d'affichage actuel + Restore View Preset Restaurer le préréglage d'affichage + Project View Preset {n} Préréglage d'affichage du projet {n} + Freeform View Options… Options de la vue libre… + Media missing Média manquant + Unreadable Illisible + Open in Source Monitor Ouvrir dans le moniteur source + Open in Timeline Ouvrir dans la timeline + New Sequence From Clip Nouvelle séquence à partir du clip + Set Poster Frame Définir l'image d'affiche + Proxy Proxy + Open in Place Ouvrir sur place + Open in New Tab Ouvrir dans un nouvel onglet + Open in New Window Ouvrir dans une nouvelle fenêtre + {n} items {n} éléments + Stack Empiler + Unstack Désempiler + Clip Size Taille des clips + Larger Plus grand + Smaller Plus petit + Align to Grid Aligner sur la grille + Reset to Grid Réinitialiser sur la grille + Save Arrangement… Enregistrer la disposition… + Restore Arrangement Restaurer la disposition + Delete Arrangement Supprimer la disposition + Drop media here to create sequence. Déposez des médias ici pour créer une séquence. + Double-click a sequence in the Project panel to open it here. Double-cliquez sur une séquence dans le panneau Projet pour l'ouvrir ici. + Open Demo Project Ouvrir le projet de démonstration + Insert and overwrite sequences as nests or individual clips Insérer et écraser des séquences en tant que séquences imbriquées ou clips individuels + Snap in Timeline (S) Magnétisme dans la timeline (S) + Caption track options Options de la piste de sous-titres + Timeline Display Settings Paramètres d'affichage de la timeline + Show Video Thumbnails Afficher les vignettes vidéo + Head Début + Head and Tail Début et fin + Continuous Continu + Show Audio Waveform Afficher la forme d'onde audio + Expand All Tracks Développer toutes les pistes + Minimize All Tracks Réduire toutes les pistes + Unrendered: should play back in real time Sans rendu : devrait être lu en temps réel + Unrendered: render to play back in real time Sans rendu : effectuez le rendu pour une lecture en temps réel + Rendered preview Aperçu rendu + ✓ Enable ✓ Activer + Unlink Dissocier + (Default) (Par défaut) + (RGB) Gamma (RGB) Gamma + (RGB) Input Black Level (RGB) Niveau de noir en entrée + (RGB) Input White Level (RGB) Niveau de blanc en entrée + (RGB) Output Black Level (RGB) Niveau de noir en sortie + (RGB) Output White Level (RGB) Niveau de blanc en sortie + + Cmd + Cmd + + Opt + Opt + 10 Bit Black Point Point noir 10 bits + 10 Bit White Point Point blanc 10 bits + 105mm Prime Focale fixe 105 mm + 29.97 fps Drop-Frame 29,97 fps drop frame + 29.97 fps Non Drop-Frame 29,97 fps non-drop frame + 35mm Prime Focale fixe 35 mm + 3D Roll Roulement 3D + 3D Rotate Rotation 3D + 3D Spin Pivotement 3D + 3D Spinback Pivotement inverse 3D + 4-Color Gradient Dégradé 4 couleurs + 5.1 Media Médias 5.1 + 5.1 Mixdown Type Type de mixage final 5.1 + 50-300mm Zoom Zoom 50-300 mm + 59.94 fps Drop-Frame 59,94 fps drop frame + 59.94 fps Non Drop-Frame 59,94 fps non-drop frame + ASC CDL ASC CDL + Accessible color contrast Contraste des couleurs accessible + Action-Safe Margin Marge de sécurité de l'action + Active Monitor Moniteur actif + Add Ajouter + Add tracks automatically when editing source clips onto the Timeline Ajouter automatiquement des pistes lors du montage de clips source dans la timeline + Place files dropped onto the Timeline directly on the Timeline Placer les fichiers déposés sur la timeline directement dans la timeline + 1 file 1 fichier + {n} files {n} fichiers + Additional Scale Échelle supplémentaire + Additive Dissolve Fondu additif + Additive Dissolve (Legacy) Fondu additif (ancien) + Adjust Régler + Advanced Avancé + Aggressive Agressif + Align and Transform Aligner et transformer + All lines Toutes les lignes + Allow Selection tool to choose Roll and Ripple trims without modifier key Autoriser l'outil Sélection à choisir les rognages par roulement et avec décalage sans touche de modification + Allow current tool to change trim type of previously selected edit point Autoriser l'outil actuel à modifier le type de rognage du point de montage précédemment sélectionné + Allow duplicate media during project import Autoriser les médias en double lors de l'importation de projets + Alpha Adjust Réglage alpha + Alpha Blurriness Flou alpha + Alpha Channel Canal alpha + Alpha Glow Lueur alpha + Always import embedded CEA-708 closed captions with wide aspect. Toujours importer les sous-titres codés CEA-708 incorporés au format large. + Always override audio tags Toujours remplacer les balises audio + Ambience Ambiance + Ambience Intensity Intensité de l'ambiance + Ambience Light Color Couleur de la lumière ambiante + Amount of Noise Quantité de bruit + Amount to Decolor Quantité de décoloration + Amount to Tint Quantité de teinte + Amplifier Amplificateur + Amplify Amplifier + Amplitude Amplitude + Amplitude and Compression Amplitude et compression + Analog Analogique + Analog Delay Retard analogique + Analyze all imported media to search for visuals or audio Analyser tous les médias importés pour rechercher des éléments visuels ou audio + Anamorphic Anamorphique + Anchor Ancre + Anchor Point Point d'ancrage + Angle Angle + Animation Animation + Anti-aliasing Quality Qualité de l'anticrénelage + Anti-flicker Filter Filtre anti-scintillement + Antialiasing for Best Quality Anticrénelage pour une qualité optimale + Asymmetry Asymétrie + At Startup Au démarrage + At playback end, return to beginning when restarting playback En fin de lecture, revenir au début lors de la reprise de la lecture + Attempt to force hardware to document sample rate Tenter de forcer le matériel à la fréquence d'échantillonnage du document + Audio Codec Codec audio + Audio Duration Durée audio + Audio Effects Effets audio + Audio Hardware Matériel audio + Audio In Point Point d'entrée audio + Audio Info Informations audio + Audio Out Point Point de sortie audio + Audio Samples Échantillons audio + Audio Time Units Unités de temps audio + Audio Transition Default Duration Durée par défaut des transitions audio + Audio Transitions Transitions audio + Audio Usage Utilisation audio + Auto Auto + Auto Align Alignement automatique + Auto Reframe Recadrage automatique + Auto Save Enregistrement automatique + Auto Save also saves the current project(s) L'enregistrement automatique enregistre aussi le ou les projets actuels + Auto-tag audio types in the timeline Baliser automatiquement les types audio dans la timeline + Auto-transcribe all imported clips Transcrire automatiquement tous les clips importés + Auto-transcribe only clips in sequences Transcrire automatiquement uniquement les clips des séquences + Automatch Time Durée de correspondance automatique + Automatic Click Remover Suppression automatique des clics + Automatically Hide Dependent Clips Masquer automatiquement les clips dépendants + Automatically Save Every Enregistrer automatiquement toutes les + Automatically delete cache files older than Supprimer automatiquement les fichiers de cache datant de plus de + Automatically delete oldest cache files when cache exceeds Supprimer automatiquement les fichiers de cache les plus anciens lorsque le cache dépasse + Automatically refresh growing files Actualiser automatiquement les fichiers en croissance + Automatically resume playback for growing files in Source Monitor Reprendre automatiquement la lecture des fichiers en croissance dans le moniteur source + Automatically save projects Enregistrer automatiquement les projets + Automatically transcribe clips Transcrire automatiquement les clips + Automation Keyframe Optimization Optimisation des images clés d'automatisation + Background Color Couleur d'arrière-plan + Background Corner Radius Rayon des angles de l'arrière-plan + Background Opacity Opacité de l'arrière-plan + Background Size Taille de l'arrière-plan + Background fill Remplissage de l'arrière-plan + Balance Balance + Balanced Voice Voix équilibrée + Band 1 Bande 1 + Band 2 Bande 2 + Band 3 Bande 3 + Band 4 Bande 4 + Band 5 Bande 5 + Band Pass Passe-bande + Band Slide Coulissement de bandes + Band Stop Coupe-bande + Bandpass Passe-bande + Barn Doors Volets + Basic De base + Basic 3D 3D de base + Basic Correction Correction de base + Bass Basses + Behind Derrière + Bessel Bessel + Bevel Alpha Biseau alpha + Bevel join Jointure biseautée + Binauralizer - Ambisonics Binauraliseur - Ambisonics + Bins Chutiers + Black Noir + Black & White Noir et blanc + Black Input Level Niveau d'entrée du noir + Black and white Noir et blanc + Black and white with crushed blacks and strong contrast Noir et blanc avec noirs écrasés et contraste marqué + Bleach Bypass Sans blanchiment + Bleach-bypass style: desaturated with hard contrast Style sans blanchiment : désaturé avec un contraste dur + Blend Fusion + Blend Mode Mode de fusion + Blend With Original Fusion avec l'original + Blending Mode Mode de fusion + Block Dissolve Fondu en blocs + Block Height Hauteur des blocs + Block Motion Mouvement des blocs + Block Size Taille des blocs + Block Width Largeur des blocs + Blocks Blocs + Blue Blurriness Flou du bleu + Blue Curve Courbe du bleu + Blue Offset Décalage du bleu + Blue Power Puissance du bleu + Blue Slope Pente du bleu + Blue-Blue Bleu-bleu + Blue-Const Bleu-constante + Blue-Green Bleu-vert + Blue-Red Bleu-rouge + Blur & Sharpen Flou et netteté + Blur Amount Quantité de flou + Blur Angle Angle du flou + Blur Dimensions Dimensions du flou + Blur Dissolve Fondu avec flou + Blur Layer Calque de flou + Blur Length Longueur du flou + Blurriness Flou + Bokeh Blur Flou bokeh + Boost Renforcement + Border Bordure + Border Color Couleur de la bordure + Border Width Largeur de la bordure + Borders Bordures + Bottom Center En bas au centre + Bottom Left En bas à gauche + Bottom Right En bas à droite + Bounce Rebond + Branching Ramification + Bright Lumineux + Bright Air Air lumineux + Bright Studio Studio lumineux + Brightness & Contrast Luminosité et contraste + British Stack Pile britannique + Brush Hardness Dureté du pinceau + Brush Opacity Opacité du pinceau + Brush Position Position du pinceau + Brush Size Taille du pinceau + Brush Spacing (secs) Espacement du pinceau (s) + Brush Strokes Coups de pinceau + Bubbles Bulles + Bulge Renflement + Bulge Smoother Lissage du renflement + Bump Height Hauteur du relief + Burn Alpha Brûlure alpha + Burn Chroma Brûlure chrominance + Burn Color Brûlure couleur + Butt cap Extrémité plate + Butterworth Butterworth + CEA-608 CEA-608 + CEA-708 CEA-708 + Cache analysis results for re-use Mettre en cache les résultats d'analyse pour les réutiliser + Camera Angle Angle de caméra + Camera Blur Flou de caméra + Camera Shake Tremblement de caméra + Capitalisation Casse + Cathedral Cathédrale + Cell Pattern Motif de cellules + Center Channel Pan Panoramique du canal central + Center Gradient Dégradé central + Center Input Entrée centrale + Center Out Du centre vers l'extérieur + Center Point (Latitude) Point central (latitude) + Center Point (Longitude) Point central (longitude) + Center Split Division centrale + Center of Sphere Centre de la sphère + Cents Cents + Change to Color Changer en couleur + Changes are copied in the background a moment after each edit. If FilmCraft quits unexpectedly, they are offered the next time it starts. Les changements sont copiés en arrière-plan peu après chaque opération de montage. Si FilmCraft se ferme de manière inattendue, ils vous sont proposés au démarrage suivant. + Channel Canal + Channel Blur Flou de canal + Channel Mix Mixage des canaux + Channel Mixer Mixeur de canaux + Chaos Chaos + Chebyshev Tchebychev + Checker Wipe Balayage en damier + Checkerboard Damier + Chinese Chinois + Choke Rétrécir + Chorus Chorus + Chorus/Flanger Chorus/Flanger + Chroma Chrominance + Chroma Leak Fuite de chrominance + Chromatic Aberration (Blue) Aberration chromatique (bleu) + Chromatic Aberration (Green) Aberration chromatique (vert) + Chromatic Aberration (Red) Aberration chromatique (rouge) + Cineon Converter Convertisseur Cineon + Circle Cercle + Clarity Clarté + Clean Combo Combo propre + Clean Up Noisy Dialogue Nettoyer les dialogues bruités + Clear Muddiness Supprimer l'effet boueux + Client Client + Clip Clip + Clip Level Niveau du clip + Clipping Écrêtage + Clock Wipe Balayage horloge + Clock Wipe (Legacy) Balayage horloge (ancien) + Clockwise Sens horaire + Clone Cloner + Closed Captions Sous-titres codés + Color & Alpha Couleur et alpha + Color 1 Couleur 1 + Color 2 Couleur 2 + Color 3 Couleur 3 + Color 4 Couleur 4 + Color Balance Balance des couleurs + Color Burn Brûlure de couleur + Color Channel Canal de couleur + Color Correction Correction colorimétrique + Color Distortion Distorsion des couleurs + Color Dodge Éclaircissement de couleur + Color Emboss Relief en couleur + Color Key Incrustation couleur + Color Pass Isolation de couleur + Color Replace Remplacement de couleur + Color Space Espace colorimétrique + Color Theme Thème de couleurs + Color To Leave Couleur à conserver + Color Tolerance Tolérance de couleur + Color Wheels & Match Roues chromatiques et correspondance + Colorize Coloriser + Columns Colonnes + Comment Commentaire + Complexity Complexité + Composite In Back Composer à l'arrière + Composite In Front Composer à l'avant + Composite Using Composer avec + Composite on Original Composer sur l'original + Compound Blur Flou composé + Compression Before Clipping Compression avant écrêtage + Constant Gain Gain constant + Constant Power Puissance constante + Conversion Type Type de conversion + Convolution Reverb Réverbération à convolution + Cool Blue Bleu froid + Cool, desaturated and punchy Froid, désaturé et percutant + Core Width Largeur du noyau + Corner Coin + Corner Pin Épinglage des coins + Count Nombre + Counter-clockwise Sens antihoraire + Crash Recovery Récupération après plantage + Crawl Left Défilement vers la gauche + Crawl Right Défilement vers la droite + Create folder for imported projects Créer un dossier pour les projets importés + Creative Créatif + Crop Recadrage + Crop Less <-> Smooth More Recadrer moins <-> Lisser plus + Cross Displacement Déplacement croisé + Cross Dissolve Fondu enchaîné + Cross Dissolve (Legacy) Fondu enchaîné (ancien) + Cross Zoom Zoom croisé + Cross Zoom (Legacy) Zoom croisé (ancien) + Crossfade Fondu croisé + Crossovers Fréquences de croisement + Crystallize Cristalliser + Crystals Cristaux + Cube Spin Rotation de cube + Curl Radius Rayon de courbure + Current Time Temps actuel + Cursor Curseur + Curvature Courbure + Curve Courbe + Curves Courbes + Custom Personnalisé + Custom Color Couleur personnalisée + Cutoff Coupure + Cyan-teal shadows and warm highlights with a little more contrast Tons foncés cyan-bleu canard et tons clairs chauds avec un peu plus de contraste + Cycle (in Revolutions) Cycle (en tours) + Cycle Evolution Évolution cyclique + Damping Amortissement + Damping HF Amortissement HF + Damping LF Amortissement BF + Dark Sombre + Darken Assombrir + Darker Color Couleur plus sombre + Date Created Date de création + Date Modified Date de modification + Day for night: darker, cooler and less saturated Nuit américaine : plus sombre, plus froid et moins saturé + DeEsser Dé-esseur + DeHummer Suppresseur de bourdonnement + DeNoise Réducteur de bruit + DeReverb Réduction de réverbération + Decay Déclin + Decay Time (RT60) Temps de déclin (RT60) + Default Audio Tracks Pistes audio par défaut + Default Emoji font Police d'emoji par défaut + Default Media Scaling Mise à l'échelle des médias par défaut + Default Output Sortie par défaut + Default closed caption font Police par défaut des sous-titres codés + Default language Langue par défaut + Default paragraph direction Sens de paragraphe par défaut + Default subtitle and text font Police par défaut des sous-titres et du texte + Delay Retard + Delay 1 Retard 1 + Delay 2 Retard 2 + Delay 3 Retard 3 + Delay 4 Retard 4 + Delay and Echo Retard et écho + Delete files older than Supprimer les fichiers datant de plus de + Delete oldest files when the cache exceeds Supprimer les fichiers les plus anciens lorsque le cache dépasse + Delete… Supprimer… + Depth Profondeur + Desaturate Désaturer + Description Description + Detail Amplitude Amplitude des détails + Detail Level Niveau de détail + Detailed Analysis Analyse détaillée + Device Class Classe de périphérique + Dialogue Dialogue + Diamond Losange + Difference Différence + Dimensions Dimensions + Dimmed Atténué + Dip to Black Fondu au noir + Dip to Black (Legacy) Fondu au noir (ancien) + Dip to Color Fondu à la couleur + Dip to White Fondu au blanc + Dip to White (Legacy) Fondu au blanc (ancien) + Direction Direction + Directional Directionnel + Directional Blur Flou directionnel + Directional Blur (Legacy) Flou directionnel (ancien) + Disable video output when in the background Désactiver la sortie vidéo en arrière-plan + Disperse Disperser + Displacement Déplacement + Display Color Management Gestion des couleurs de l'écran + Display Color Settings Paramètres de couleur de l'écran + Display out of sync indicators for unlinked clips Afficher les indicateurs de désynchronisation des clips dissociés + Dissolve Fondu enchaîné + Dissolve Blocks Blocs de fondu + Distance Distance + Distance to Image Distance à l'image + Distort Déformer + Distortion Distorsion + Distortion Complexity Complexité de la distorsion + Distortion Rate Vitesse de la distorsion + Distortion Type Type de distorsion + Divide Diviser + Do not delete cache files automatically Ne pas supprimer automatiquement les fichiers de cache + Don't label speakers Ne pas étiqueter les intervenants + Double-click Double-clic + Draft decoding at reduced playback resolution (H.264: faster, some frames less filtered) Décodage brouillon à résolution de lecture réduite (H.264 : plus rapide, certaines images moins filtrées) + Drive Saturation + Drop Shadow Ombre portée + Dry Signal sec + Dry Out Sortie sèche + Dry Up a Roomy Voice Assécher une voix trop réverbérée + Duck Deep Under Dialogue Atténuer fortement sous les dialogues + Duck Under Dialogue Atténuer sous les dialogues + Duck Under Voice and Effects Atténuer sous la voix et les effets + Ducking Atténuation automatique + Dutch Néerlandais + Dynamic Link Dynamic Link + Dynamics Processing Traitement de la dynamique + Early Reflections Premières réflexions + Earthquake Tremblement de terre + Ease In and Out Lissage à l'entrée et à la sortie + Easing Lissage + Echo Écho + Echo Glow Lueur d'écho + Echo Operator Opérateur d'écho + Echo Time (seconds) Temps d'écho (secondes) + Echoes Échos + Edge Color Couleur du contour + Edge Feather Contour progressif des bords + Edge Glow Lueur des contours + Edge Sharpness Netteté du bord + Edge Softness Adoucissement du bord + Edge Thickness Épaisseur du bord + Edge Thin Amincir le bord + Edge Threshold Seuil du bord + Edge Type Type de bord + Edge Width Largeur du bord + Elliptical Elliptique + Emboss Estampage + Enable HSL Secondary Activer HSL secondaire + Enable Mercury Transmit Activer Mercury Transmit + Enable ProRes hardware accelerated encoding, if available Activer l'encodage ProRes accéléré par le matériel, si disponible + Enable clip and XMP metadata linking Activer la liaison des métadonnées de clip et XMP + Enable developer mode Activer le mode développeur + Enable hardware accelerated decoding (requires restart) Activer le décodage accéléré par le matériel (redémarrage requis) + Enable language auto-detection Activer la détection automatique de la langue + Enable proxies Activer les proxys + End Color Couleur de fin + End Point Point de fin + End of Ramp Fin de la rampe + Error Erreur + Evolution Évolution + Evolution Options Options d'évolution + Exciter Amount Quantité d'excitation + Exciter Mode Mode d'excitation + Exclusion Exclusion + Exponential Fade Fondu exponentiel + Extended Dynamic Range Monitoring Contrôle en plage dynamique étendue + FFT Filter Filtre FFT + Fade Fondu + Fade Out Fondu de sortie + Faded Matte Cache délavé + Falloff Atténuation + Far Away Lointain + Fast Rapide + Faster Motion Mouvement plus rapide + Feedback Réinjection + Feedback 1 Réinjection 1 + Feedback 2 Réinjection 2 + Feedback 3 Réinjection 3 + Feedback 4 Réinjection 4 + Feet + Frames 16mm Pieds + images 16 mm + Feet + Frames 35mm Pieds + images 35 mm + Female Femme + File Size Taille du fichier + Fill Background Remplir l'arrière-plan + Fill Color Couleur de remplissage + Fill Left with Right Remplir la gauche avec la droite + Fill Point Point de remplissage + Fill Right with Left Remplir la droite avec la gauche + Fill Selector Sélecteur de remplissage + Film Dissolve Fondu enchaîné film + Film Dissolve (Legacy) Fondu enchaîné film (ancien) + Film Roll Bobine de film + Filter Filtre + Filter Frequency Fréquence du filtre + Filter Resonance Résonance du filtre + Filter and EQ Filtre et égaliseur + Final Delay Retard final + Find Edges Détection des contours + Flanger Flanger + Flare Reflet + Flare Brightness Luminosité du reflet + Flare Center Centre du reflet + Flare Color Couleur du reflet + Flash Flash + Flicker Scintillement + Flickers Scintillements + Flip Motion Mouvement de retournement + Flip Over Retourner + Float Virgule flottante + Focus Mise au point + Focus Blur Flou de mise au point + Focus Point Point de mise au point + Focus Shape Forme de la mise au point + Focus Size Taille de la mise au point + Fold Motion Mouvement de pliage + Foldback Retour + For color management parameters, go to the Settings tab in the Lumetri Color panel. Pour les paramètres de gestion des couleurs, accédez à l'onglet Paramètres du panneau Couleur Lumetri. + Format Format + Four Scopes (RGB, 8 bit) Quatre oscilloscopes (RGB, 8 bits) + Four Scopes (YUV, float, no clamp) Quatre oscilloscopes (YUV, virgule flottante, sans écrêtage) + Fractal Influence Influence fractale + Fractal Type Type de fractale + Frame Image + Frame Color Couleur du cadre + Frame Count Nombre d'images + Frame Layout Disposition de l'image + Frame Rate Fréquence d'images + Frame Width Largeur du cadre + Frame cache for monitors and thumbnails Cache d'images pour les moniteurs et les vignettes + Frames Images + Framing Cadrage + French Français + Frequency Fréquence + From De + From East Depuis l'est + From North Depuis le nord + From South Depuis le sud + From West Depuis l'ouest + From the Left Depuis la gauche + From the Right Depuis la droite + Front + LFE Avant + LFE + Front + Rear Avant + arrière + Front + Rear + LFE Avant + arrière + LFE + Front Only Avant uniquement + Fuzz Fuzz + GB Go + Gamma Gamma + Gamma Correction Correction gamma + Gamut Warning Avertissement de gamut + Gamut Warning Color Couleur d'avertissement de gamut + Gap Espace vide + Gap Color Couleur des espaces vides + Gaussian Blur Flou gaussien + Gaussian Blur (Legacy) Flou gaussien (ancien) + General Général + Generate Générer + Generate Timecode Générer le timecode + Generate waveforms and conform audio using multithreading Générer les formes d'onde et conformer l'audio en multithread + Generate waveforms automatically during import Générer automatiquement les formes d'onde lors de l'importation + German Allemand + Ghosts Fantômes + Glass Verre + Glint Scintillement lumineux + Glitch Glitch + Glow Lueur + Glow Brightness Luminosité de la lueur + Glow Color Couleur de la lueur + Glow Only Lueur uniquement + Glow Radius Rayon de la lueur + Glow Saturation Saturation de la lueur + Glow Width Largeur de la lueur + Golden Hour Heure dorée + Good Bonne + Gradient Dégradé + Gradient End Fin du dégradé + Gradient Layer Calque de dégradé + Gradient Placement Placement du dégradé + Gradient Scatter Dispersion du dégradé + Gradient Shape Forme du dégradé + Gradient Start Début du dégradé + Gradient Wipe Balayage en dégradé + Gradient Wipe (Legacy) Balayage en dégradé (ancien) + Grain Size Taille du grain + Graphic Equalizer (10 Bands) Égaliseur graphique (10 bandes) + Graphic Equalizer (20 Bands) Égaliseur graphique (20 bandes) + Graphic Equalizer (30 Bands) Égaliseur graphique (30 bandes) + Graphics Graphiques + Green Blurriness Flou du vert + Green Curve Courbe du vert + Green Offset Décalage du vert + Green Power Puissance du vert + Green Slope Pente du vert + Green-Blue Vert-bleu + Green-Const Vert-constante + Green-Green Vert-vert + Green-Red Vert-rouge + Grid Grille + Grow Croissance + Growing Files Fichiers en croissance + Grunge Grunge + Grunge & Distort Grunge et distorsion + GuitarSuite GuitarSuite + HDR Graphics White (Nits) Blanc des graphiques HDR (nits) + HLS Vectorscope + Histogram Vectorscope HLS + histogramme + HSL Secondary HSL secondaire + Hard Dur + Hard Clip Écrêtage dur + Hard Light Lumière dure + Hard Limiter Limiteur dur + Hard Mix Mélange dur + Hardware decoding Décodage matériel + Hardware decoding: Auto uses the system's video decoder (VideoToolbox on macOS, Media Foundation on Windows, VA-API or NVDEC for H.264 and HEVC on Linux) for the streams it supports, and FilmCraft's own decoder for everything else or if the hardware fails. Media that is already open keeps its decoder until it is reopened. Décodage matériel : Auto utilise le décodeur vidéo du système (VideoToolbox sur macOS, Media Foundation sur Windows, VA-API ou NVDEC pour H.264 et HEVC sous Linux) pour les flux qu'il prend en charge, et le décodeur propre à FilmCraft pour tout le reste ou en cas d'échec du matériel. Les médias déjà ouverts conservent leur décodeur jusqu'à leur réouverture. + Head Size Taille de la tête + Heart Cœur + Height Hauteur + Hexagon Hexagone + Hide Back Face Masquer la face arrière + High Élevée + High Cutoff Coupure des aigus + High Frequency Cut Coupure des hautes fréquences + High Frequency Damping Amortissement des hautes fréquences + High Pass Passe-haut + High Shelf Plateau aigu + High Shelf Gain Gain du plateau aigu + High contrast with held highlights and deep shadows Contraste élevé avec tons clairs préservés et tons foncés profonds + Highlight Ton clair + Highlight Blue Balance Balance du bleu des tons clairs + Highlight Brightness Luminosité des tons clairs + Highlight Color Couleur de surbrillance + Highlight Green Balance Balance du vert des tons clairs + Highlight Red Balance Balance du rouge des tons clairs + Highlight Rolloff Atténuation des tons clairs + Highlight Threshold Seuil des tons clairs + Highlight Tint Nuance des tons clairs + Highlights Lightness Luminosité des tons clairs + Highpass Passe-haut + Hindi Hindi + Hindi digits Chiffres hindis + Horizontal 16:9 Horizontal 16:9 + Horizontal Alignment Alignement horizontal + Horizontal Blocks Blocs horizontaux + Horizontal Decentering Décentrage horizontal + Horizontal Displacement Déplacement horizontal + Horizontal Flip Retournement horizontal + Horizontal Margin Marge horizontale + Horizontal and Vertical Horizontal et vertical + Hue Center Centre de teinte + Hue Tolerance Tolérance de teinte + I/O Buffer Size Taille du tampon d'E/S + Ignore Alpha Ignorer l'alpha + Image Control Contrôle de l'image + Immersive Video Vidéo immersive + Import numbered stills as image sequences Importer les images fixes numérotées comme séquences d'images + Impulse Impulsion + In a Room Dans une pièce + In the Media Cache Dans le cache des médias + In the project Dans le projet + In/Out Duration Durée de l'entrée à la sortie + Include captions on import Inclure les sous-titres lors de l'importation + Incoming Entrant + Indeterminate Media Timebase Base temporelle des médias indéterminée + Initial Delay Retard initial + Inner Intérieur + Inner Radius Rayon intérieur + Input Boost Amplification d'entrée + Inset Encart + Inside Intérieur + Inside Color Couleur intérieure + Inter Inter + Interior Room Tone Ambiance de pièce intérieure + Internal Black Point Point noir interne + Internal White Point Point blanc interne + Interpolation Interpolation + Intersect Intersection + Inverse Falloff Atténuation inverse + Invert Inverser + Invert Alpha Inverser l'alpha + Invert Blur Inverser le flou + Invert Fill Inverser le remplissage + Invert Gradient Inverser le dégradé + Invert Left Inverser la gauche + Invert Right Inverser la droite + Iris Iris + Iris Box Iris carré + Iris Cross Iris en croix + Iris Diamond Iris en losange + Iris Radius Rayon de l'iris + Iris Rotation Rotation de l'iris + Iris Round Iris rond + Iris Shape Forme de l'iris + Italian Italien + Japanese Japonais + JetBrains Mono JetBrains Mono + Join Through Edits Joindre les points de montage traversants + Ripple Trim In Rogner l'entrée avec décalage + Ripple Trim Out Rogner la sortie avec décalage + Roll Edit Montage par roulement + Trim In Rogner l'entrée + Trim Out Rogner la sortie + Kaleidoscope Kaléidoscope + Keep a recovery copy of unsaved changes Conserver une copie de récupération des modifications non enregistrées + Key Color Couleur d'incrustation + Keying Incrustation + Korean Coréen + Label Defaults Étiquettes par défaut + Label speakers Étiqueter les intervenants + Labels Étiquettes + Large Hall Grande salle + Large Trim Offset Grand décalage de rognage + Large Volume Adjustment Réglage de volume important + Latch Verrouillage + Layer Name Nom du calque + Layers Calques + Leave Color Conserver une couleur + Left - Left Gauche - gauche + Left - Right Gauche - droite + Left channel to device output Canal gauche vers la sortie du périphérique + Left to Right De gauche à droite + Left to right De gauche à droite + Legacy Anciens + Length Longueur + Lens Blur Flou de l'objectif + Lens Distortion Distorsion de l'objectif + Lens Flare Reflet d'objectif + Lens Type Type d'objectif + Level 1 Niveau 1 + Level 2 Niveau 2 + Level 3 Niveau 3 + Level 4 Niveau 4 + Levels Niveaux + Lifted blacks, softer contrast, muted colour Noirs relevés, contraste plus doux, couleurs atténuées + Light 1 Lumière 1 + Light 2 Lumière 2 + Light 3 Lumière 3 + Light 4 Lumière 4 + Light 5 Lumière 5 + Light Angle Angle de la lumière + Light Color Couleur de la lumière + Light Intensity Intensité de la lumière + Light Leak Fuite de lumière + Light Leaks Fuites de lumière + Light Sweep Balayage lumineux + Light Type Type de lumière + Lighten Éclaircir + Lighter Color Couleur plus claire + Lighting Effects Effets d'éclairage + Lightning Éclair + Lights & Blurs Lumières et flous + Lights & Glows Lumières et lueurs + Line Thickness Épaisseur de ligne + Linear Burn Assombrissement linéaire + Linear Dodge (Add) Éclaircissement linéaire (ajout) + Linear Light Lumière linéaire + Linear Ramp Rampe linéaire + Linear Wipe Balayage linéaire + Linear Wipe (Legacy) Balayage linéaire (ancien) + Linear keyframe thinning Réduction linéaire des images clés + Linear to Log Linéaire vers log + Liquid Distortion Distorsion liquide + Local Projects Projets locaux + Location Emplacement + Log Note Note de journal + Log to Linear Log vers linéaire + Log to Log Log vers log + Logo Cutout Découpe de logo + Long Shadow Ombre longue + Look-Ahead Time Temps d'anticipation + Loudness Sonie + Loudness Maximizer Maximiseur de sonie + Loudness Meter Sonomètre + Louver Persienne + Low Faible + Low Frequency Cut Coupure des basses fréquences + Low Pass Passe-bas + Low Shelf Plateau grave + Low Shelf Gain Gain du plateau grave + Lower Left En bas à gauche + Lower Right En bas à droite + Lowpass Passe-bas + Luma Luma + Luma Curve Courbe de luma + Luma Fade Fondu de luma + Luma Key Incrustation en luma + Luma Source Source de luma + Luma and Chroma Luma et chroma + Lumetri Presets Préréglages Lumetri + Luminance Luminance + Luminance Threshold Seuil de luminance + Luminosity Luminosité + MB Mo + Magenta Magenta + Magnify Loupe + Magnify (Legacy) Loupe (ancien) + Maintain pitch while shuttling Conserver la hauteur tonale pendant le défilement + Major Radius Grand rayon + Male Homme + Mango Mangue + Map Black To Associer le noir à + Map White To Associer le blanc à + Maps full-range levels into 16–235 (video range) with a luma curve Convertit les niveaux pleine plage en 16–235 (plage vidéo) avec une courbe de luma + Mask Only Masque uniquement + Master Amplitude Amplitude générale + Mastering Mastering + Match frame sets in point L'image correspondante définit le point d'entrée + Matte Cache + Matte Alpha Cache alpha + Matte Cleanup Nettoyage du cache + Matte Generation Génération du cache + Matte Luma Cache de luma + Maximum Maximum + Maximum Amplitude Amplitude maximale + Maximum Block Size Taille de bloc maximale + Maximum Blur Flou maximal + Maximum Project Versions Nombre maximal de versions du projet + Maximum Reduction Réduction maximale + Maximum Scale Échelle maximale + Media Médias + Media Analysis Analyse des médias + Media Analysis & Transcription Analyse et transcription des médias + Media Cache Cache des médias + Media Cache Database Base de données du cache des médias + Media Cache Files Fichiers du cache des médias + Media Cache Management Gestion du cache des médias + Media Duration Durée du média + Media End Fin du média + Media Time Temps du média + Media Timecode Timecode du média + Media Type Type de média + Media analysis and transcriptions will be cached in the Media Cache folder. Reanalysis won't be required if the media is imported into another project on this computer. Les analyses et transcriptions des médias seront mises en cache dans le dossier du cache des médias. Aucune nouvelle analyse ne sera nécessaire si les médias sont importés dans un autre projet sur cet ordinateur. + Median Médiane + Medium Room Pièce moyenne + Memory Mémoire + Metadata & Timecode Burn-in Incrustation des métadonnées et du timecode + Method Méthode + Metrics Kerning Crénage métrique + Mid Médiums + Mid Point Point médian + Midtone Blue Balance Balance du bleu des tons moyens + Midtone Green Balance Balance du vert des tons moyens + Midtone Red Balance Balance du rouge des tons moyens + Midtones Lightness Luminosité des tons moyens + Minimum Minimum + Minimum time Durée minimale + Minimum time interval thinning Réduction par intervalle de temps minimal + Minor Radius Petit rayon + Mirror Miroir + Mirror Alternate Miroir alterné + Missing font replacement Remplacement des polices manquantes + Miter join Jointure en onglet + Miter limit Limite de l'onglet + Mixed Crystals Cristaux mélangés + Mode Mode + Modern High Gain Gain élevé moderne + Modulation Modulation + Modulation Rate Vitesse de modulation + Mono Media Médias mono + Monochrome Monochrome + Monochrome toned cyan-blue Monochrome viré bleu cyan + Monochrome toned warm brown Monochrome viré brun chaud + Monoscopic Monoscopique + Monospace only Chasse fixe uniquement + More colour in the less saturated areas Plus de couleur dans les zones moins saturées + Morph Cut Fondu morphologique + Mosaic Mosaïque + Mosaic (Legacy) Mosaïque (ancien) + Motion Blur Flou de mouvement + Motion Camera Caméra en mouvement + Motion Preset Préréglage de mouvement + Motion Tween Interpolation de mouvement + Move Déplacer + Movie Film + Movie (audio and video) Film (audio et vidéo) + Multiband Compressor Compresseur multibande + Multichannel Mono Media Médias mono multicanaux + Multiply Multiplier + Multitap Delay Retard multiprise + Music Musique + Mute input during timeline recording Couper le son de l'entrée pendant l'enregistrement dans la timeline + Name Nom + Narrow Étroit + Neon Color Couleur néon + Neon Wipe Balayage néon + Next to original media files À côté des fichiers médias d'origine + Night Nuit + No Motion Aucun mouvement + No Scroll Aucun défilement + No plugins are installed. Changes will take effect the next time you start FilmCraft. Aucun plug-in n'est installé. Les modifications prendront effet au prochain démarrage de FilmCraft. + Noise Bruit + Noise & Grain Bruit et grain + Noise (Legacy) Bruit (ancien) + Noise Level Niveau de bruit + Noise Reduction/Restoration Réduction du bruit/restauration + Noise Type Type de bruit + Non-Additive Dissolve Fondu enchaîné non additif + Normal Normal + Notch 1 Coupe-bande 1 + Notch 2 Coupe-bande 2 + Notch 3 Coupe-bande 3 + Notch 4 Coupe-bande 4 + Notch 5 Coupe-bande 5 + Notch 6 Coupe-bande 6 + Notch Filter Filtre coupe-bande + Notch Width Largeur du coupe-bande + Number Of Echoes Nombre d'échos + Number of Harmonics Nombre d'harmoniques + Obsolete Obsolètes + Octagon Octogone + Offset Décalage + Offset (Turbulence) Décalage (turbulence) + Offset From Décaler depuis + Offset To Décaler jusqu'à + Omni Omni + On Activé + On Original Image Sur l'image d'origine + On Transparent Sur fond transparent + Open Air Plein air + Open Most Recent Ouvrir le plus récent + Open in new window Ouvrir dans une nouvelle fenêtre + Open in place Ouvrir sur place + Open new tab Ouvrir un nouvel onglet + Opens up the shadows Débouche les tons foncés + Order Ordre + Orientation Orientation + Outer Extérieur + Outgoing Sortant + Output Sortie + Output Mapping Affectation des sorties + Outside Extérieur + Outside Color Couleur extérieure + Overflow Dépassement + Overlay Superposition + Overshoot Dépassement de crête + Page Peel Page décollée + Page Scroll Défilement de page + Page Turn Page tournée + Paint Bucket Pot de peinture + Paint On Black Peindre sur du noir + Paint On Original Image Peindre sur l'image d'origine + Paint On Transparent Peindre sur fond transparent + Paint On White Peindre sur fond blanc + Paint Style Style de peinture + Paint Surface Surface de peinture + Pan (Y Axis) Panoramique (axe Y) + Panel Wipe Balayage par panneaux + Panels Panneaux + Panner - Ambisonics Panoramique - Ambisonics + Parade Parade + Parade (RGB) Parade (RGB) + Parametric Equalizer Égaliseur paramétrique + Passband Ripple Ondulation de la bande passante + Path Tracé + Path Points Points du tracé + Pause Media Encoder queue during playback Suspendre la file d'encodage pendant la lecture + Peak Frequency Fréquence de crête + Peak Gain Gain de crête + Pedestal Piédestal + Pentagon Pentagone + Per line Par ligne + Percent Blur Pourcentage de flou + Perspective Perspective + Phase Difference Différence de phase + Phaser Phaser + Phone Call Appel téléphonique + Phosphor Phosphore + Phosphor Color Couleur du phosphore + Photocopy Photocopie + Photocopy Color Couleur de photocopie + Pillow Coussin + Pin All Tout épingler + Pin Horizontal Épingler horizontalement + Pin Light Lumière ponctuelle + Pin Vertical Épingler verticalement + Pinning Épinglage + Pitch Shifter Modificateur de hauteur + Plate Plaque + Plateau Wipe Balayage en plateaux + Plateaus Plateaux + Plates Plaques + Play after rendering previews Lire après le rendu des aperçus + Play audio while scrubbing in Source and Program Monitors Lire l'audio pendant le défilement dans les moniteurs source et programme + Playback Lecture + Playhead position determines trim monitor loop playback La position de la tête de lecture détermine la lecture en boucle du moniteur de rognage + Plugins Modules externes + Podcast Voice Voix de podcast + Point 1 Point 1 + Point 1 Color Couleur du point 1 + Point 1 Latitude Latitude du point 1 + Point 1 Longitude Longitude du point 1 + Point 2 Point 2 + Point 2 Color Couleur du point 2 + Point 2 Latitude Latitude du point 2 + Point 2 Longitude Longitude du point 2 + Point 3 Point 3 + Point 3 Color Couleur du point 3 + Point 3 Latitude Latitude du point 3 + Point 3 Longitude Longitude du point 3 + Point 4 Point 4 + Point 5 Point 5 + Point 6 Point 6 + Point 7 Point 7 + Point 8 Point 8 + Points Points + Policy Politique + Pop Motion Mouvement surgissant + Portuguese Portugais + Position & Rotation Position et rotation + Position Amount Quantité de position + Position, Scale & Rotation Position, échelle et rotation + Position, Scale, Rotation Position, échelle, rotation + Posterize Postériser + Posterize Time Postériser le temps + Pre-Delay Pré-retard + Prefix Préfixe + Presence Lift Accentuation de la présence + Preserve Luminosity Conserver la luminosité + Preserve Scale Conserver l'échelle + ProcAmp ProcAmp + Projecting cap Extrémité en saillie + Projects Projets + Pull Motion Mouvement de traction + Pulls highlights and whites down Abaisse les tons clairs et les blancs + Push Pousser + Push (Legacy) Pousser (ancien) + Q Q + RAM reserved for other applications RAM réservée aux autres applications + RGB RGB + RGB Split Séparation RGB + RGB-White RGB-blanc + Radial Radial + Radial Blur Flou radial + Radial Ramp Dégradé radial + Radial Wipe Balayage radial + Radial Wipe (Legacy) Balayage radial (ancien) + Radio Announcer Animateur radio + Radio Voice Voix radio + Radius Rayon + Ramp Dégradé + Ramp Shape Forme du dégradé + Random Aléatoire + Random Seed Graine aléatoire + Range Plage + Ray Rayon lumineux + Ray Color Couleur des rayons + Ray Length Longueur des rayons + Rays Rayons + Rays Only Rayons uniquement + Read Lecture + Rec. 2100 Rec. 2100 + Rec. 601 Rec. 601 + Rec. 709 Rec. 709 + Red Blurriness Flou du rouge + Red Curve Courbe du rouge + Red Offset Décalage du rouge + Red Power Puissance du rouge + Red Slope Pente du rouge + Red-Blue Rouge-bleu + Red-Const Rouge-constante + Red-Green Rouge-vert + Red-Red Rouge-rouge + Reduce Axis Réduire l'axe + Reduce Interlace Flicker Réduire le scintillement d'entrelacement + Reflected Réfléchi + Reflection Angle Angle de réflexion + Reflection Center Centre de réflexion + Refraction Réfraction + Reframe Offset Décalage du cadrage + Reframe Zoom Zoom du cadrage + Refresh growing Files Every Actualiser les fichiers en croissance toutes les + Relaxed Détendu + Release Time Temps de relâchement + Relief Relief + Remaining Restant + Remove Fringe Supprimer la frange + Render Edit in Audition files to: Effectuer le rendu des fichiers modifiés dans Audition vers : + Render audio when rendering video Effectuer le rendu de l'audio lors du rendu de la vidéo + Repair Réparer + Repeat Edge Pixels Répéter les pixels du bord + Replace Color Remplacer la couleur + Replace With Clip From Source Monitor Remplacer par le clip du moniteur source + Replicate Répliquer + Responsive Design - Position Conception adaptative - position + Responsive Design - Time Conception adaptative - temps + Restore open sequences when opening projects Restaurer les séquences ouvertes à l'ouverture des projets + Result Résultat + Retro Rétro + Reveal Nested Sequence Afficher la séquence imbriquée + Reveal Original Image Afficher l'image d'origine + Reveal in Project Afficher dans le projet + Reverb Amount Quantité de réverbération + Reverse Inverser + Right - Left Droite - gauche + Right - Right Droite - droite + Right channel to device output Canal droit vers la sortie du périphérique + Right to left De droite à gauche + Ripple trim adds edits to keep both sides of trim in sync Le rognage avec décalage ajoute des points de montage pour garder les deux côtés synchronisés + Roll (Z Axis) Roulis (axe Z) + Room Size Taille de la pièce + Rotation Amount Quantité de rotation + Roughen Rugosité + Roughen Color Couleur de rugosité + Roughen Edges Bords rugueux + Round cap Extrémité arrondie + Round join Jointure arrondie + Rounded Crop Recadrage arrondi + Rows Lignes + Russian Russe + Rusty Rouillé + Rusty Color Couleur rouille + SFX Effets sonores + Sample Rate Fréquence d'échantillonnage + Saturation at 50 % Saturation à 50 % + Save .cfa and .pek media cache files next to original media files when possible Enregistrer les fichiers de cache média .cfa et .pek à côté des fichiers média d'origine si possible + Scale (Degrees) Échelle (degrés) + Scale Amount Quantité d'échelle + Scale From Mettre à l'échelle depuis + Scale Height Échelle de la hauteur + Scale Match Correspondance d'échelle + Scale To Mettre à l'échelle vers + Scale Width Échelle de la largeur + Scale to frame size Mettre à l'échelle selon la taille de l'image + Scan Lines Lignes de balayage + Scene Scène + Scientific Filter Filtre scientifique + Scratch disk location for Captured Audio Emplacement du disque de travail pour l'audio capturé + Screen Écran + Seconds Secondes + Segments Segments + Semi-tones Demi-tons + Sequence Name Nom de la séquence + Sequence Timecode Timecode de la séquence + Set focus on the Timeline when performing Insert/Overwrite edits Placer le focus sur la timeline lors des montages par insertion/écrasement + Set to frame size Définir sur la taille de l'image + Setting Paramètre + Shadow Angle Angle de l'ombre + Shadow Blue Balance Balance du bleu dans les tons foncés + Shadow Blur Flou de l'ombre + Shadow Color Couleur de l'ombre + Shadow Distance Distance de l'ombre + Shadow Green Balance Balance du vert dans les tons foncés + Shadow Only Ombre seule + Shadow Opacity Opacité de l'ombre + Shadow Red Balance Balance du rouge dans les tons foncés + Shadow Size Taille de l'ombre + Shadow Tint Teinte des tons foncés + Shadows Lightness Luminosité des tons foncés + Shake Tremblement + Shape Dissolve Fondu par formes + Shape Flow Flux de formes + Shape Size Taille de la forme + Shapes Formes + Shard Size Taille des éclats + Sharp Colors Couleurs nettes + Sharpen Amount Quantité de netteté + Shift Center To Déplacer le centre vers + Shift clips that overlap trim point during ripple trimming Décaler les clips qui chevauchent le point de rognage lors d'un rognage avec décalage + Shot Plan + Show "Project:" and "Bin:" in Project panel tab Afficher « Projet : » et « Chutier : » dans l'onglet du panneau Projet + Show Clip Mismatch Warning dialog Afficher l'avertissement de non-concordance de clip + Show Event Indicator Afficher l'indicateur d'événement + Show Focus Area Afficher la zone de mise au point + Show Home Afficher l'accueil + Show Mask Tracker Preview Afficher l'aperçu du suivi de masque + Show Only Noise Afficher uniquement le bruit + Show Open Dialog Afficher la boîte de dialogue Ouvrir + Show Project Load Error Dialog Afficher la boîte de dialogue d'erreur de chargement du projet + Show Rich Tool Tips Afficher les info-bulles enrichies + Show Tool Tips Afficher les info-bulles + Show Workspace Reset Warning dialog on double click Afficher l'avertissement de réinitialisation de l'espace de travail lors d'un double-clic + Show system compatibility issues at startup Afficher les problèmes de compatibilité système au démarrage + Shrink Rétrécir + Shutter Angle Angle d'obturation + Similarity Similarité + Simple Notch Filter Filtre coupe-bande simple + Simple Parametric EQ Égaliseur paramétrique simple + Simple Text Texte simple + Single-band Compressor Compresseur monobande + Sinusoidal Sinusoïdal + Skew Biais + Skew Axis Axe du biais + Slats Lamelles + Slice Tranche + Slices Tranches + Slide Coulissement + Slide (Legacy) Coulissement (ancien) + Slope Pente + Slow Lent + Slower Motion Ralenti accentué + Small Room Petite pièce + Smart Limit Limitation intelligente + Smart Tools Outils intelligents + Smart quotes Guillemets typographiques + Smooth Lisse + Smooth Motion Mouvement fluide + Smooth Scroll Défilement fluide + Smoothness Lissage + Snap playhead in Timeline when Snap is enabled Aligner la tête de lecture dans la timeline lorsque le magnétisme est activé + Soft Doux + Soft Clamp Écrêtage doux + Soft Edges (Best Quality) Bords adoucis (qualité optimale) + Soft Light Lumière tamisée + Soft Wipe Balayage doux + Soften Harshness Atténuer la dureté + Softness Adoucissement + Solarize Solariser + Solid Colors Couleurs unies + Source Point Point source + Source Text Texte source + Spacer Espaceur + Spanish Espagnol + Speaker Angle Angle du haut-parleur + Speaker Labeling Identification des intervenants + Special Spécial + Special Effects Effets spéciaux + Speech model Modèle de parole + Speed Vitesse + Spherize Sphériser + Spiky Hérissé + Spill Débordement + Spill Suppression Suppression du débordement + Spin Tournoiement + Spin Direction Sens du tournoiement + Spin Motion Mouvement de tournoiement + Split Séparation + Split (Legacy) Séparation (ancien) + Spotlight Projecteur + Spread Étalement + Spring Motion Mouvement élastique + Square Carré + Square (Width Only) Carré (largeur uniquement) + Square 1:1 Carré 1:1 + Stabilization Stabilisation + Stabilize Only Stabiliser uniquement + Stabilize, Crop Stabiliser, recadrer + Stabilize, Crop, Auto-scale Stabiliser, recadrer, mise à l'échelle auto + Stabilize, Synthesize Edges Stabiliser, synthétiser les bords + Stages Étapes + Star Étoile + Star Wipe Balayage en étoile + Start Angle Angle de départ + Start Color Couleur de départ + Start Point Point de départ + Start at 0 Commencer à 0 + Start at 1 Commencer à 1 + Start of Ramp Début du dégradé + Start with an Empty Project Commencer avec un projet vide + Starting Intensity Intensité de départ + Static Plates Plaques statiques + Step forward/back many Avancer/reculer de plusieurs images + Stereo Expand Élargissement stéréo + Stereo Expander Élargisseur stéréo + Stereo Imagery Image stéréo + Stereo Media Médias stéréo + Stereo Phasing Phase stéréo + Stereoscopic - Over/Under Stéréoscopique - dessus/dessous + Stereoscopic - Side by Side Stéréoscopique - côte à côte + Still Image fixe + Still Image Default Duration Durée par défaut des images fixes + Stone Church Église en pierre + Stopband Attenuation Atténuation de la bande coupée + Straight Color Couleur directe + Strength Intensité + Stretch Étirement + Stretch Gradient to Fit Étirer le dégradé pour l'adapter + Stretch Map to Fit Étirer la carte pour l'adapter + Stretch Width or Height Étirer la largeur ou la hauteur + Stretch Wipe Balayage étiré + Stretch to Fit Frame Étirer à la taille de l'image + Stripe Rayure + Stripes Rayures + Strobe Color Couleur du stroboscope + Strobe Duration (secs) Durée du stroboscope (s) + Strobe Light Lumière stroboscopique + Strobe Period (secs) Période du stroboscope (s) + Stroke 2 Color Couleur du contour 2 + Stroke 2 Type Type du contour 2 + Stroke 2 Width Largeur du contour 2 + Stroke Angle Angle du trait + Stroke Color Couleur du contour + Stroke Density Densité des traits + Stroke Length Longueur du trait + Stroke Length (secs) Longueur du trait (s) + Stroke Only Contour uniquement + Stroke Randomness Caractère aléatoire des traits + Stroke Type Type de contour + Stroke Width Largeur du contour + Stroke line cap Extrémité du contour + Stroke line join Jointure du contour + Studio Reverb Réverbération de studio + Stylize Styliser + Subclip End Fin du sous-clip + Subclip Start Début du sous-clip + Subspace Warp Déformation de sous-espace + Subtitle Sous-titre + Subtract Soustraire + Sum multichannel outputs to mono in Source Monitor Mixer les sorties multicanaux en mono dans le moniteur source + Super Narrow Extra-étroit + Surface Gloss Brillance de la surface + Surface Material Matériau de la surface + Surround Reverb Réverbération surround + Swap Permuter + Swap Channels Permuter les canaux + Swivel Pivotement + Synthetic Synthétique + TV Power Allumage TV + Tape Bande + Tape Name Nom de la bande + Tape/Tube Bande/lampe + Target Aspect Format cible + Target Color Couleur cible + Target Field of View Champ de vision cible + Target Loudness Sonie cible + Teal & Orange Bleu canard et orange + Telephone Téléphone + Teletext Télétexte + Template Properties Propriétés du modèle + Text Animator Animateur de texte + Text Color Couleur du texte + The media cache holds render previews of unsaved projects and other files FilmCraft can recreate. Deleting them is always safe; FilmCraft rebuilds them as needed. Le cache média contient les aperçus de rendu des projets non enregistrés et d'autres fichiers que FilmCraft peut recréer. Vous pouvez toujours les supprimer sans risque ; FilmCraft les reconstruit au besoin. + Thickness Épaisseur + Tile Gradient Dégradé en mosaïque + Tilt Inclinaison + Tilt (X Axis) Inclinaison (axe X) + Time Temps + Time and Pitch Temps et hauteur + Timecode Conversion Conversion du timecode + Timeline Mouse Scrolling Défilement à la souris dans la timeline + Timeline Playback Auto-Scrolling Défilement automatique de la timeline pendant la lecture + Tint Color Couleur de teinte + To À + Tolerance Tolérance + Tone Ton + Top Center En haut au centre + Top Left En haut à gauche + Top Right En haut à droite + Touch Toucher + Track Matte Key Incrustation par cache de piste + Transcription Transcription + Transcription preferences Préférences de transcription + Transform Transformation + Transformers Transformateurs + Transience Transitoires + Transition Completion Achèvement de la transition + Transition Softness Adoucissement de la transition + Transparency Transparence + Trash Corbeille + Travel Motion Mouvement de déplacement + Treble Aigus + Triangle Triangle + Trim Rognage + Tube Lampe + Tube-modeled Compressor Compresseur à modélisation de lampe + Tubular Tubulaire + Turbulent Turbulent + Turbulent Displace Déplacement turbulent + Turbulent Sharp Turbulent net + Turbulent Smooth Turbulent lisse + Turbulent Smoother Turbulent plus lisse + Tweed Tweed + Twirl Tourbillon + Twirl (Legacy) Tourbillon (ancien) + Twirl Center Centre du tourbillon + Twirl Radius Rayon du tourbillon + Twist Torsion + Twist Smoother Torsion plus lisse + Typewriter Machine à écrire + Ultra Key Ultra Key + Uniform Scale Échelle uniforme + Uniform Spacing Espacement uniforme + Unsharp Mask Masque flou + Update it at least every Mettre à jour au moins toutes les + Update timeline dynamically during ripple edits. Mettre à jour la timeline dynamiquement pendant les montages avec décalage. + Upper Frequency Fréquence supérieure + Upper Left En haut à gauche + Upper Right En haut à droite + Use Color Noise Utiliser un bruit coloré + Use End Color Utiliser la couleur de fin + Use File Utiliser un fichier + Use Glow Color Utiliser la couleur de lueur + Use Media Source Utiliser la source du média + Use Tint Color Utiliser la couleur de teinte + Utility Utilitaire + VHS Damage Dégradation VHS + VR Blur Flou VR + VR Chroma Leaks Fuites chromatiques VR + VR Chromatic Aberrations Aberrations chromatiques VR + VR Color Gradients Dégradés de couleur VR + VR De-Noise Réduction du bruit VR + VR Digital Glitch Glitch numérique VR + VR Fractal Noise Bruit fractal VR + VR Glow Lueur VR + VR Gradient Wipe Balayage en dégradé VR + VR Iris Wipe Balayage en iris VR + VR Light Leaks Fuites de lumière VR + VR Light Rays Rayons lumineux VR + VR Mobius Zoom Zoom de Möbius VR + VR Plane to Sphere Plan vers sphère VR + VR Projection Projection VR + VR Random Blocks Blocs aléatoires VR + VR Rotate Sphere Rotation de sphère VR + VR Sharpen Netteté VR + VR Spherical Blur Flou sphérique VR + Validate Content Credentials on import Vérifier les identifiants de contenu à l'importation + Vectorscope (YUV) Vectorscope (YUV) + Vectorscope + Waveform YC Vectorscope + forme d'onde YC + Vectorscope HLS Vectorscope HLS + Vectorscope YUV Vectorscope YUV + Venetian Blinds Store vénitien + Vertical 4:5 Vertical 4:5 + Vertical 9:16 Vertical 9:16 + Vertical Alignment Alignement vertical + Vertical Blocks Blocs verticaux + Vertical Decentering Décentrage vertical + Vertical Displacement Déplacement vertical + Vertical Flip Retournement vertical + Vertical Margin Marge verticale + Very Narrow Très étroit + Video 1 Vidéo 1 + Video 2 Vidéo 2 + Video 3 Vidéo 3 + Video 4 Vidéo 4 + Video 5 Vidéo 5 + Video 6 Vidéo 6 + Video 7 Vidéo 7 + Video 8 Vidéo 8 + Video Codec Codec vidéo + Video Duration Durée de la vidéo + Video Effects Effets vidéo + Video In Point Point d'entrée vidéo + Video Info Informations vidéo + Video Limiter Limiteur vidéo + Video Out Point Point de sortie vidéo + Video Transition Default Duration Durée par défaut des transitions vidéo + Video Transitions Transitions vidéo + Video Usage Utilisation vidéo + Vignette Vignettage + Vivid Light Lumière vive + Vocal Booth Cabine vocale + Vocal Enhancer Optimiseur vocal + Voice Voix + Voice in a Hall Voix dans une salle + Volumetric Rays Rayons volumétriques + Warm Film Pellicule chaude + Warm Room Pièce chaleureuse + Warm Voice Voix chaleureuse + Warm print stock with gentle roll-off Pellicule de tirage chaude avec une atténuation douce + Warm, soft late-afternoon light Lumière chaude et douce de fin d'après-midi + Warning Avertissement + Warp Stabilizer Stabilisateur de déformation + Wave Vague + Wave Height Hauteur de la vague + Wave Speed Vitesse de la vague + Wave Warp Déformation ondulée + Wave Width Largeur de la vague + Waveform Forme d'onde + Waveform (Luma) Forme d'onde (luma) + Waveform (RGB) Forme d'onde (RGB) + Wavelength Longueur d'onde + Wet Signal traité + Wet Out Sortie traitée + When Opening a Project À l'ouverture d'un projet + Whip Fouetté + Whip (Legacy) Fouetté (ancien) + Whisper base (balanced) Whisper base (équilibré) + Whisper small (most accurate) Whisper small (le plus précis) + Whisper tiny (fastest) Whisper tiny (le plus rapide) + White Blanc + White Input Level Niveau d'entrée du blanc + White Is High Le blanc est haut + Wide Outdoor Grand extérieur + Widener Élargisseur + Width Largeur + Wiggle Oscillation + Wipe Balayage + Wipe Angle Angle du balayage + Wonder Glow Lueur merveilleuse + Wrap Back Enroulement arrière + Write Écriture + Write XMP ID to files on import Écrire l'ID XMP dans les fichiers à l'importation + Write clip markers to XMP Écrire les marqueurs de clip dans XMP + Write-on Écriture progressive + X Rotation Rotation X + Y Rotation Rotation Y + YC YC + YC no Chroma YC sans chrominance + YUV YUV + Yellow-green highlights and blue-lifted shadows from crossed channel curves Tons clairs jaune-vert et tons foncés relevés vers le bleu par des courbes de canaux croisées + Z Position Position Z + Z Rotation Rotation Z + Zoom Zoom + Zoom Amount Quantité de zoom + Zoom Blur Flou de zoom + Zoom Strength Intensité du zoom + days jours + frames images + milliseconds millisecondes + minute(s) minute(s) + second(s) seconde(s) + seconds secondes + AAF… AAF… + Audio… Audio… + Avid Log Exchange… Avid Log Exchange… + Color… Couleur… + EDL… EDL… + Edit Workspaces… Modifier les espaces de travail… + FCPXML… FCPXML… + Final Cut Pro XML… Final Cut Pro XML… + General… Général… + OMF… OMF… + OpenTimelineIO… OpenTimelineIO… + Save Changes to this Workspace Enregistrer les modifications de cet espace de travail + Save as New Workspace… Enregistrer comme nouvel espace de travail… + Create Search Bin Créer un chutier de recherche + Edit Offline File Modifier le fichier hors ligne + Source Settings Paramètres de la source + Update Metadata Mettre à jour les métadonnées + Automate To Sequence Automatiser vers la séquence + Scene Edit Detection Détection des changements de scène + Analyze Analyser + Normalize Mix Track Normaliser la piste de mixage + Simplify Sequence Simplifier la séquence + Simplify Simplifier + Transcribe Sequence Transcrire la séquence + Save as Template Enregistrer comme modèle + Flash Cue Marker Marqueur de repère Flash + System Compatibility Report Rapport de compatibilité système + no GPU adapter: the CPU compositor is used aucun adaptateur GPU : le compositeur CPU est utilisé + Avid Log Exchange Avid Log Exchange + FilmCraft Project Projet FilmCraft + {name} Selection.fcproj {name} Sélection.fcproj + {name} (Simplified) {name} (simplifiée) + Find in: Rechercher dans : + Find: Rechercher : + Search: Rechercher : + Clip Names and Markers Noms de clips et marqueurs + Match: Correspondance : + All Tous + Any N'importe lequel + Case Sensitive Respecter la casse + Scratch Disks Disques de travail + Ingest Settings Paramètres d'ingestion + Captured and Generated: Capturés et générés : + Video Previews: Aperçus vidéo : + Audio Previews: Aperçus audio : + Project Auto Save: Enregistrement automatique du projet : + Same as Project Identique au projet + Path: {path} Chemin : {path} + Transcode Transcoder + Copy and Create Proxies Copier et créer des proxys + Action: Action : + Destination: Destination : + Next to the media À côté des médias + Video Rendering and Playback Rendu et lecture vidéo + Renderer: Moteur de rendu : + Display Format: Format d'affichage : + Milliseconds Millisecondes + Capture Capture + Capture Format: Format de capture : + Action and Title Safe Areas Zones de sécurité action et titre + Title Safe Area Zone de sécurité titre + Action Safe Area Zone de sécurité action + Total Duration Durée totale + Average Data Rate Débit moyen + Colour Couleur + {codec} · {rate} Hz · {n} channel(s) {codec} · {rate} Hz · {n} canal(aux) + Media Name: Nom du média : + Scene: Scène : + Shot/Take: Plan/prise : + Log Note: Note de dérushage : + Codec: {codec} Codec : {codec} + Write the metadata of the selected clip(s) ({n}) to XMP files next to their media? Écrire les métadonnées des clips sélectionnés ({n}) dans des fichiers XMP à côté de leurs médias ? + Existing XMP files written by other applications are left unchanged. Les fichiers XMP existants écrits par d'autres applications ne sont pas modifiés. + To {name} Vers {name} + Ordering: Ordre : + Sort Order Ordre de tri + Selection Order Ordre de sélection + Placement: Placement : + Sequentially Séquentiellement + At Unnumbered Markers Aux marqueurs non numérotés + Method: Méthode : + Insert Edit Montage par insertion + Overwrite Edit Montage par écrasement + Clip Overlap: Chevauchement des clips : + Frames per Still: Images par image fixe : + Transitions Transitions + Apply Default Audio Transition Appliquer la transition audio par défaut + Apply Default Video Transition Appliquer la transition vidéo par défaut + Ignore Options Options d'exclusion + Ignore Audio Ignorer l'audio + Ignore Video Ignorer la vidéo + Apply a cut at each detected cut point Appliquer une coupe à chaque point de coupe détecté + Create a subclip for each detected cut point Créer un sous-clip pour chaque point de coupe détecté + Generate clip markers at each detected cut point Générer des marqueurs de clip à chaque point de coupe détecté + Sensitivity: Sensibilité : + Normalize Mix Track to: Normaliser la piste de mixage à : + Disabled clips Clips désactivés + Empty tracks Pistes vides + Gaps Espaces vides + Video effects Effets vidéo + Audio effects Effets audio + Text (graphics and captions) Texte (graphiques et sous-titres) + Clips Clips + Move clips to the lowest track possible Déplacer les clips vers la piste la plus basse possible + Keep: Conserver : + Video and Audio Vidéo et audio + Video Only Vidéo uniquement + Audio Only Audio uniquement + Language: Langue : + Audio on track {t} Audio de la piste {t} + Audio analysis: Analyse audio : + Recognize when different speakers are talking Reconnaître les changements d'intervenant + Template Name: Nom du modèle : + Comments: Commentaires : + (unavailable) (indisponible) + Decoders Décodeurs + Containers Conteneurs + Export formats Formats d'exportation + Match {n} of {total} Résultat {n} sur {total} + Saved template {path} Modèle enregistré : {path} + Scene Edit Detection: analysing in the background (see Progress) Détection des changements de scène : analyse en arrière-plan (voir Progression) + Delete Search Bin Supprimer le chutier de recherche + (removed from {list}) (retiré de {list}) + {key} can't be used as a shortcut {key} ne peut pas être utilisé comme raccourci + Keyboard Shortcuts Raccourcis clavier + {active} (modified) {active} (modifié) + Loaded “{name}” « {name} » chargé + My Shortcuts Mes raccourcis + Deleted preset “{active}” Préréglage « {active} » supprimé + Export… Exporter… + Exported to {path} Exporté vers {path} + Export: no file chosen (agents: shortcuts.export {path}) Exporter : aucun fichier choisi (agents : shortcuts.export {path}) + Imported “{name}” « {name} » importé + Import: no file chosen (agents: shortcuts.import {path}) Importer : aucun fichier choisi (agents : shortcuts.import {path}) + Keyboard: US (macOS) Clavier : américain (macOS) + Keyboard: US Clavier : américain + Save preset as: Enregistrer le préréglage sous : + Commands: Commandes : + {panel} Panel Panneau {panel} + Application shortcuts work whatever panel has focus Les raccourcis d'application fonctionnent quel que soit le panneau actif + Panel shortcuts override application shortcuts while that panel has focus Les raccourcis de panneau remplacent ceux de l'application tant que ce panneau est actif + Modifier held / selected Modificateur maintenu / sélectionné + Search commands or keys Rechercher des commandes ou des touches + Selected: {command} Sélection : {command} + Command Commande + Shortcut Raccourci + Type a shortcut… (Esc cancels) Saisissez un raccourci… (Échap pour annuler) + Add another shortcut Ajouter un autre raccourci + No matching commands Aucune commande correspondante + Key: Touche : + Modifiers Modificateurs + Nothing is assigned to this key Rien n'est attribué à cette touche + Click a key on the keyboard to see its shortcuts Cliquez sur une touche du clavier pour voir ses raccourcis + Assign {key} to {command} Attribuer {key} à {command} + Conflict: {key} is assigned to {commands} ({ctx}) Conflit : {key} est attribué à {commands} ({ctx}) + and et + Click a command's Shortcut cell and press keys to assign it; + adds another shortcut. Cliquez sur la cellule Raccourci d'une commande et appuyez sur des touches pour l'attribuer ; + ajoute un autre raccourci. + Cleared {n} shortcut(s) of {command} {n} raccourci(s) de {command} effacé(s) + No export preset named `{name}` Aucun préréglage d'exportation nommé `{name}` + Open a sequence to export Ouvrez une séquence à exporter + Destinations Destinations + Media File Fichier média + Queue ({n}) File d'attente ({n}) + Send exports here with Send to Queue, then start the queue. Envoyez des exportations ici avec Envoyer vers la file d'attente, puis lancez la file d'attente. + Ready Prêt + Encoding {n}% Encodage {n} % + Start Queue Lancer la file d'attente + Stop Arrêter + Clear Finished Effacer les éléments terminés + Not supported by FilmCraft's encoders yet Pas encore pris en charge par les encodeurs de FilmCraft + Sequence name Nom de la séquence + Folder Dossier + Preset Préréglage + More presets… Plus de préréglages… + Multiplexer Multiplexeur + The sequence has no caption track. La séquence ne comporte aucune piste de sous-titres. + Export Options Options d'exportation + Burn Captions Into Video Incruster les sous-titres dans la vidéo + Create Sidecar File Créer un fichier annexe + File Format Format de fichier + SubRip Subtitle (.srt) Sous-titres SubRip (.srt) + WebVTT (.vtt) WebVTT (.vtt) + Title Titre + Creator Créateur + Copyright Copyright + Image Image + Frame Size Taille d'image + Match Source Identique à la source + Field Order Ordre des trames + Aspect Format + Profile Profil + Bitrate Encoding Encodage du débit + Target Bitrate Débit cible + Adaptive ({bpp} bits per pixel) Adaptatif ({bpp} bits par pixel) + Maximum Bitrate Débit maximal + Quality (CRF) Qualité (CRF) + Key Frame Distance Intervalle entre images clés + Quality Qualité + Render at Maximum Depth Rendu à la profondeur maximale + Use Maximum Render Quality Utiliser la qualité de rendu maximale + Export Audio Exporter l'audio + Audio Format Format audio + Uncompressed (PCM) Non compressé (PCM) + Match Source ({seq_sr} Hz) Identique à la source ({seq_sr} Hz) + Channels Canaux + Audio Bitrate Débit audio + Sample Size Taille d'échantillon + 16 bit 16 bits + 24 bit 24 bits + Size / Opacity Taille / opacité + Image Overlay Superposition d'image + PNG or JPEG file Fichier PNG ou JPEG + Name Overlay Superposition du nom + Timecode Overlay Superposition du timecode + Luma Min / Max Luminance min. / max. + Loudness Normalization Normalisation de la sonie + True Peak Limit Limite de crête réelle + Audio only Audio uniquement + Scaling Mise à l'échelle + Scale To Fit Mettre à l'échelle pour adapter + Scale To Fill Mettre à l'échelle pour remplir + Stretch To Fill Étirer pour remplir + Summary Résumé + Sequence, {w}x{h}, {fps} fps, {rate} Hz Séquence, {w}x{h}, {fps} fps, {rate} Hz + Estimated file size: {size} Taille de fichier estimée : {size} + Send to Queue Envoyer vers la file d'attente + Exporting {path} Exportation de {path} + Added to the export queue Ajouté à la file d'attente d'exportation + Preset Manager Gestionnaire de préréglages + Search presets Rechercher des préréglages + Favorites only Favoris uniquement + Show in the Preset menu Afficher dans le menu Préréglage + User Utilisateur + Save current settings as: Enregistrer les paramètres actuels sous : + Export presets Préréglages d'exportation + Import: no file chosen (agents: export.presets.import {path}) Importer : aucun fichier choisi (agents : export.presets.import {path}) + Export: no file chosen (agents: export.presets.export {path, names}) Exporter : aucun fichier choisi (agents : export.presets.export {path, names}) + Quick Export: open a sequence first Exportation rapide : ouvrez d'abord une séquence + File Name & Location Nom et emplacement du fichier + Quick Export: {path} Exportation rapide : {path} + Graphics problem: switched to software compositing Problème graphique : passage à la composition logicielle + GPU acceleration is unavailable; preview renders on the CPU L'accélération GPU n'est pas disponible ; l'aperçu est rendu par le CPU + Exporting Exportation + Click to select, or click in empty space and drag to marquee select. Use Shift, Opt, and Cmd for other options. Cliquez pour sélectionner, ou cliquez dans un espace vide et faites glisser pour sélectionner par un rectangle. Utilisez Maj, Option et Cmd pour d'autres options. + Click to select all clips to the right in all tracks. Shift-click for a single track. Cliquez pour sélectionner tous les clips à droite sur toutes les pistes. Maj+clic pour une seule piste. + Click to select all clips to the left in all tracks. Shift-click for a single track. Cliquez pour sélectionner tous les clips à gauche sur toutes les pistes. Maj+clic pour une seule piste. + Drag an edit point to ripple trim; later clips move to keep the gap closed. Faites glisser un point de montage pour un montage avec décalage ; les clips suivants se déplacent pour ne laisser aucun espace vide. + Drag an edit point to roll it: the out of one clip and the in of the next move together. Faites glisser un point de montage pour un montage par roulement : la sortie d'un clip et l'entrée du suivant se déplacent ensemble. + Drag an edge to change the clip's speed so it fills the new duration. Faites glisser un bord pour modifier la vitesse du clip afin qu'il remplisse la nouvelle durée. + Drag the edge of a music clip to remix it to the new duration at musically matching beats. Faites glisser le bord d'un clip musical pour le remixer à la nouvelle durée sur des temps musicalement cohérents. + Click to split a clip. Shift-click to split all tracks. Cliquez pour scinder un clip. Maj+clic pour scinder toutes les pistes. + Drag a clip to slip its source in/out without moving it. Faites glisser un clip pour décaler ses points d'entrée/sortie source sans le déplacer. + Drag a clip to slide it between its neighbours. Faites glisser un clip pour le faire coulisser entre ses voisins. + Drag to scroll the timeline. Faites glisser pour faire défiler la timeline. + Click to zoom in; Opt-click to zoom out. Cliquez pour un zoom avant ; Option+clic pour un zoom arrière. + FilmCraft hit an error FilmCraft a rencontré une erreur + Details: {path} Détails : {path} + Opened {url} {url} ouvert + (empty) (vide) + Step Back 1 Frame Reculer d'une image + Step Forward 1 Frame Avancer d'une image + Match Playhead Faire correspondre la tête de lecture + Gang to Program Coupler au moniteur programme + AIFF AIFF + AIFF 48 kHz 16-bit AIFF 48 kHz 16 bits + AIFF, stereo 48 kHz 16-bit PCM AIFF, PCM stéréo 48 kHz 16 bits + APV APV + APV 422-10 APV 422-10 + APV 422-12 APV 422-12 + Anamorphic 2:1 (2.0) Anamorphique 2:1 (2.0) + Animated GIF GIF animé + Animated GIF 640×360 GIF animé 640×360 + Apple ProRes Apple ProRes + Apple ProRes 422 Apple ProRes 422 + Apple ProRes 422 HQ Apple ProRes 422 HQ + Apple ProRes 422 LT Apple ProRes 422 LT + Apple ProRes 422 Proxy Apple ProRes 422 Proxy + Apple ProRes 4444 Apple ProRes 4444 + Apple ProRes 4444 with Alpha Apple ProRes 4444 avec alpha + Apple ProRes 4444 XQ Apple ProRes 4444 XQ + Application Application + Apply Guide Template Appliquer un modèle de repères + Auto Save Now Enregistrer automatiquement maintenant + Auto-Match Loudness Harmoniser automatiquement la sonie + Avid DNxHR Avid DNxHR + Avid DNxHR HQ Avid DNxHR HQ + Avid DNxHR LB Avid DNxHR LB + Avid DNxHR SQ Avid DNxHR SQ + Avid-style OP-Atom: DNxHR HQ picture file plus one 24-bit 48 kHz PCM file per channel (_A1, _A2…) OP-Atom de type Avid : fichier image DNxHR HQ plus un fichier PCM 24 bits 48 kHz par canal (_A1, _A2…) + BMP BMP + BMP Sequence Séquence BMP + Baseline Baseline + Begin Text Editing for a Graphic Layer Commencer l'édition du texte d'un calque graphique + Begins With Commence par + Blur In Entrée floue + Blur Out Sortie floue + Brightens inside a feathered circular mask Éclaircit l'intérieur d'un masque circulaire à contour progressif + Broadcast Diffusion + CBR CBR + Cancel Dynamic Trim Annuler le rognage dynamique + Cancel Queued Export Annuler l'exportation en file d'attente + Center Left Centre gauche + Center Right Centre droit + Center align text Centrer le texte + Change Mask Modifier le masque + Clear All Events Effacer tous les événements + Clear Edit Point Selection Effacer la sélection de points de montage + Clear Finished Exports Effacer les exportations terminées + Clear Poster Frame Effacer l'image d'affiche + Close Bin Fermer le chutier + Close Sequence Fermer la séquence + Contains Contient + Cut to Camera 1 Couper sur la caméra 1 + Cut to Camera 2 Couper sur la caméra 2 + Cut to Camera 3 Couper sur la caméra 3 + Cut to Camera 4 Couper sur la caméra 4 + Cut to Camera 5 Couper sur la caméra 5 + Cut to Camera 6 Couper sur la caméra 6 + Cut to Camera 7 Couper sur la caméra 7 + Cut to Camera 8 Couper sur la caméra 8 + Cut to Camera 9 Couper sur la caméra 9 + D1/DV NTSC (0.9091) D1/DV NTSC (0.9091) + D1/DV PAL (1.0940) D1/DV PAL (1.0940) + DVCPRO HD (1.5) DVCPRO HD (1.5) + Darkens outside a feathered oval mask Assombrit l'extérieur d'un masque ovale à contour progressif + Decrease Audio Tracks Height Réduire la hauteur des pistes audio + Decrease Clip Volume Baisser le volume du clip + Decrease Clip Volume Many Baisser fortement le volume du clip + Decrease Font Size by Five Units Réduire la taille de police de cinq unités + Decrease Font Size by One Unit Réduire la taille de police d'une unité + Decrease Leading by Five Units Réduire l'interlignage de cinq unités + Decrease Leading by One Unit Réduire l'interlignage d'une unité + Decrease Video Tracks Height Réduire la hauteur des pistes vidéo + Delete Captions Supprimer les sous-titres + Delete Graphic Layer Supprimer le calque graphique + Delete Guide Template Supprimer le modèle de repères + Delete Media Cache Files Supprimer les fichiers du cache des médias + Discard Unsaved Changes Abandonner les modifications non enregistrées + Does Not Contain Ne contient pas + Download Speech Model Télécharger le modèle vocal + Dynamic Trim Stop Arrêter le rognage dynamique + Edit Caption Text Modifier le texte du sous-titre + Ellipse Tool Outil Ellipse + Ends With Se termine par + Entire Source Source entière + EssentialSound Son essentiel + Export Frame Exporter l'image + Extend Next Edit To Playhead Étendre le point de montage suivant jusqu'à la tête de lecture + Extend Previous Edit To Playhead Étendre le point de montage précédent jusqu'à la tête de lecture + Extend Selected Edit to Playhead Étendre le point de montage sélectionné jusqu'à la tête de lecture + Extend Selection Down Étendre la sélection vers le bas + Extend Selection Left Étendre la sélection vers la gauche + Extend Selection Right Étendre la sélection vers la droite + Extend Selection Up Étendre la sélection vers le haut + Fade In Fondu d'entrée + Gaussian Blur from 0 to 60 over the last second Flou gaussien de 0 à 60 sur la dernière seconde + Gaussian Blur from 60 to 0 over the first second Flou gaussien de 60 à 0 sur la première seconde + Generate Ducking Keyframes Générer des images clés d'atténuation + Go to Next Edit Point Aller au point de montage suivant + Go to Next Edit Point on Any Track Aller au point de montage suivant sur n'importe quelle piste + Go to Previous Edit Point Aller au point de montage précédent + Go to Previous Edit Point on Any Track Aller au point de montage précédent sur n'importe quelle piste + Go to Selected Clip End Aller à la fin du clip sélectionné + Go to Selected Clip Start Aller au début du clip sélectionné + Go to Sequence End Aller à la fin de la séquence + Go to Sequence Start Aller au début de la séquence + H.264 H.264 + AV1 AV1 + H.264 1920×1080 for video sharing sites, VBR 1 pass, 16 Mbps H.264 1920×1080 pour sites de partage vidéo, VBR 1 passe, 16 Mbps + H.264 1920×1080, VBR 1 pass, 20 Mbps target, AAC 320 kbps H.264 1920×1080, VBR 1 passe, 20 Mbps cible, AAC 320 kbps + H.264 1920×1080, VBR 2 pass, 18 Mbps H.264 1920×1080, VBR 2 passes, 18 Mbps + H.264 3840×2160 for video sharing sites, VBR 1 pass, 45 Mbps H.264 3840×2160 pour sites de partage vidéo, VBR 1 passe, 45 Mbps + H.264 3840×2160, VBR 1 pass, 60 Mbps target, AAC 320 kbps H.264 3840×2160, VBR 1 passe, 60 Mbps cible, AAC 320 kbps + H.264 3840×2160, VBR 2 pass, 50 Mbps H.264 3840×2160, VBR 2 passes, 50 Mbps + H.264 9:16 portrait for short-form social video, scaled to fill, 12 Mbps H.264 9:16 portrait pour vidéos courtes sur les réseaux sociaux, mis à l'échelle pour remplir, 12 Mbps + H.264 at the sequence's frame size and rate; 0.05 bits per pixel H.264 à la taille et à la fréquence d'images de la séquence ; 0,05 bit par pixel + H.264 at the sequence's frame size and rate; 0.1 bits per pixel H.264 à la taille et à la fréquence d'images de la séquence ; 0,1 bit par pixel + H.264 at the sequence's frame size and rate; bitrate adapts to the frame size (0.2 bits per pixel) H.264 à la taille et à la fréquence d'images de la séquence ; le débit s'adapte à la taille d'image (0,2 bit par pixel) + HD Anamorphic 1080 (1.333) HD anamorphique 1080 (1.333) + Hand Tool Outil Main + High Quality 1080p HD Haute qualité 1080p HD + High Quality 2160p 4K Haute qualité 2160p 4K + Image Sequence Séquence d'images + Increase Audio Tracks Height Augmenter la hauteur des pistes audio + Increase Clip Volume Augmenter le volume du clip + Increase Clip Volume Many Augmenter fortement le volume du clip + Increase Font Size by Five Units Augmenter la taille de police de cinq unités + Increase Font Size by One Unit Augmenter la taille de police d'une unité + Increase Leading by Five Units Augmenter l'interlignage de cinq unités + Increase Leading by One Unit Augmenter l'interlignage d'une unité + Increase Video Tracks Height Augmenter la hauteur des pistes vidéo + Large blocks resolving to the picture over the first second Gros blocs se résolvant en image sur la première seconde + Left align text Aligner le texte à gauche + Looping GIF, 640×360 at 15 fps GIF en boucle, 640×360 à 15 fps + Lower First Trame inférieure en premier + Lumetri Lumetri + MXF OP-Atom MXF OP-Atom + MXF OP-Atom DNxHR (Avid) MXF OP-Atom DNxHR (Avid) + MXF OP1a MXF OP1a + MXF OP1a DNxHR HQ MXF OP1a DNxHR HQ + MXF OP1a H.264 MXF OP1a H.264 + MXF OP1a ProRes 422 HQ MXF OP1a ProRes 422 HQ + MXF OP1a, DNxHR HQ (8-bit 4:2:2), 24-bit 48 kHz PCM, start timecode MXF OP1a, DNxHR HQ (8 bits 4:2:2), PCM 24 bits 48 kHz, timecode de début + MXF OP1a, H.264 High long GOP (0.3 bits per pixel), 24-bit 48 kHz PCM MXF OP1a, H.264 High GOP long (0,3 bit par pixel), PCM 24 bits 48 kHz + MXF OP1a, ProRes 422 HQ, 24-bit 48 kHz PCM, start timecode MXF OP1a, ProRes 422 HQ, PCM 24 bits 48 kHz, timecode de début + Main Main + Mark In (Hover Scrub) Marquer l'entrée (défilement au survol) + Mark Out (Hover Scrub) Marquer la sortie (défilement au survol) + Masks Masques + Match Source – Adaptive High Bitrate Identique à la source – débit adaptatif élevé + Match Source – Adaptive Low Bitrate Identique à la source – débit adaptatif faible + Match Source – Adaptive Medium Bitrate Identique à la source – débit adaptatif moyen + Matches Correspond à + Maximize or Restore Active Frame Agrandir ou restaurer le cadre actif + Maximize or Restore Frame Under Cursor Agrandir ou restaurer le cadre sous le pointeur + Media Browser Settings Paramètres de l'Explorateur de médias + MediaBrowser Explorateur de médias + MediaCache Cache des médias + Merge Captions Fusionner les sous-titres + Mixer Mixeur + Mosaic In Entrée en mosaïque + Move All Audio Targets Down Descendre toutes les cibles audio + Move All Audio Targets Up Monter toutes les cibles audio + Move All Video Targets Down Descendre toutes les cibles vidéo + Move All Video Targets Up Monter toutes les cibles vidéo + Move Selection Down Déplacer la sélection vers le bas + Move Selection End Déplacer la sélection à la fin + Move Selection Home Déplacer la sélection au début + Move Selection Left Déplacer la sélection vers la gauche + Move Selection Page Down Déplacer la sélection d'une page vers le bas + Move Selection Page Up Déplacer la sélection d'une page vers le haut + Move Selection Right Déplacer la sélection vers la droite + Move Selection Up Déplacer la sélection vers le haut + Multi-Camera Record On/Off Toggle Activer/désactiver l'enregistrement multicaméra + Multicam Multicaméra + Navigate to End of Segment Aller à la fin du segment + Navigate to Next Line Aller à la ligne suivante + Navigate to Next Word Aller au mot suivant + Navigate to Previous Line Aller à la ligne précédente + Navigate to Previous Word Aller au mot précédent + Navigate to Start of Segment Aller au début du segment + Navigation Navigation + Next Multi-Camera Page Page multicaméra suivante + Nudge Clip Selection Down Déplacer la sélection de clips vers le bas + Nudge Clip Selection Left Five Frames Déplacer la sélection de clips de cinq images vers la gauche + Nudge Clip Selection Left One Frame Déplacer la sélection de clips d'une image vers la gauche + Nudge Clip Selection Right Five Frames Déplacer la sélection de clips de cinq images vers la droite + Nudge Clip Selection Right One Frame Déplacer la sélection de clips d'une image vers la droite + Nudge Clip Selection Up Déplacer la sélection de clips vers le haut + Nudge Selected Object down by five Déplacer l'objet sélectionné de cinq unités vers le bas + Nudge Selected Object down by one Déplacer l'objet sélectionné d'une unité vers le bas + Nudge Selected Object to left by five Déplacer l'objet sélectionné de cinq unités vers la gauche + Nudge Selected Object to left by one Déplacer l'objet sélectionné d'une unité vers la gauche + Nudge Selected Object to right by five Déplacer l'objet sélectionné de cinq unités vers la droite + Nudge Selected Object to right by one Déplacer l'objet sélectionné d'une unité vers la droite + Nudge Selected Object up by five Déplacer l'objet sélectionné de cinq unités vers le haut + Nudge Selected Object up by one Déplacer l'objet sélectionné d'une unité vers le haut + Nudge Volume +1dB Ajuster le volume de +1 dB + Nudge Volume +3dB Ajuster le volume de +3 dB + Nudge Volume -1dB Ajuster le volume de -1 dB + Nudge Volume -3dB Ajuster le volume de -3 dB + Numbered BMP stills (24-bit) Images fixes BMP numérotées (24 bits) + Numbered PNG stills at the sequence's frame size Images fixes PNG numérotées à la taille d'image de la séquence + Numbered TIFF stills (RGB, 8-bit) Images fixes TIFF numérotées (RGB, 8 bits) + Opacity 0 → 100 % over the first second Opacité 0 → 100 % sur la première seconde + Opacity 100 → 0 % over the last second Opacité 100 → 0 % sur la dernière seconde + Open Bin Ouvrir le chutier + Open Search Ouvrir la recherche + PNG PNG + PNG Sequence Séquence PNG + Panel Panneau + Pen Tool Outil Plume + PiP 35% Upper Right PiP 35 % en haut à droite + Picture-in-picture: 35 % scale in the upper right Image dans l'image : échelle de 35 % en haut à droite + Play Around Lire autour + Play In to Out with Preroll/Postroll Lire de l'entrée à la sortie avec pré-roll/post-roll + Play from Playhead to Out Point Lire de la tête de lecture au point de sortie + Play/Stop Lire/arrêter + Previous Multi-Camera Page Page multicaméra précédente + Progressive Progressif + ProjectPanel Panneau Projet + QuickTime (Motion JPEG) QuickTime (Motion JPEG) + QuickTime, APV 422-10 (10-bit 4:2:2), 24-bit 48 kHz PCM QuickTime, APV 422-10 (10 bits 4:2:2), PCM 24 bits 48 kHz + QuickTime, APV 422-12 (12-bit 4:2:2), 24-bit 48 kHz PCM QuickTime, APV 422-12 (12 bits 4:2:2), PCM 24 bits 48 kHz + QuickTime, DNxHR HQ (8-bit 4:2:2), 24-bit 48 kHz PCM QuickTime, DNxHR HQ (8 bits 4:2:2), PCM 24 bits 48 kHz + QuickTime, DNxHR LB (8-bit 4:2:2), 24-bit 48 kHz PCM QuickTime, DNxHR LB (8 bits 4:2:2), PCM 24 bits 48 kHz + QuickTime, DNxHR SQ (8-bit 4:2:2), 24-bit 48 kHz PCM QuickTime, DNxHR SQ (8 bits 4:2:2), PCM 24 bits 48 kHz + QuickTime, ProRes 422 HQ, 24-bit 48 kHz PCM QuickTime, ProRes 422 HQ, PCM 24 bits 48 kHz + QuickTime, ProRes 422 LT, 24-bit 48 kHz PCM QuickTime, ProRes 422 LT, PCM 24 bits 48 kHz + QuickTime, ProRes 422 Proxy, 24-bit 48 kHz PCM QuickTime, ProRes 422 Proxy, PCM 24 bits 48 kHz + QuickTime, ProRes 4444 (4:4:4), 24-bit 48 kHz PCM QuickTime, ProRes 4444 (4:4:4), PCM 24 bits 48 kHz + QuickTime, ProRes 4444 (4:4:4) with alpha channel, 24-bit 48 kHz PCM QuickTime, ProRes 4444 (4:4:4) avec couche alpha, PCM 24 bits 48 kHz + QuickTime, ProRes 4444 XQ (4:4:4), 24-bit 48 kHz PCM QuickTime, ProRes 4444 XQ (4:4:4), PCM 24 bits 48 kHz + QuickTime, ProRes 422, 24-bit 48 kHz PCM QuickTime, ProRes 422, PCM 24 bits 48 kHz + Quit FilmCraft Quitter FilmCraft + Rate Stretch Tool Outil Étirement de la vitesse + Razor Tool Outil Cutter + Rectangle Tool Outil Rectangle + Remix Tool Outil Remixage + Right align text Aligner le texte à droite + Ripple Edit Tool Outil Montage avec décalage + Ripple Trim Next Edit to Playhead Rogner avec décalage le point de montage suivant jusqu'à la tête de lecture + Ripple Trim Previous Edit to Playhead Rogner avec décalage le point de montage précédent jusqu'à la tête de lecture + Rolling Edit Tool Outil Montage par roulement + Select Camera 1 Sélectionner la caméra 1 + Select Camera 2 Sélectionner la caméra 2 + Select Camera 3 Sélectionner la caméra 3 + Select Camera 4 Sélectionner la caméra 4 + Select Camera 5 Sélectionner la caméra 5 + Select Camera 6 Sélectionner la caméra 6 + Select Camera 7 Sélectionner la caméra 7 + Select Camera 8 Sélectionner la caméra 8 + Select Camera 9 Sélectionner la caméra 9 + Select Clip at Playhead Sélectionner le clip sous la tête de lecture + Select Find Box Sélectionner le champ de recherche + Select Next Clip Sélectionner le clip suivant + Select Next Panel Sélectionner le panneau suivant + Select Previous Clip Sélectionner le clip précédent + Select Previous Panel Sélectionner le panneau précédent + Select to Next Line Sélectionner jusqu'à la ligne suivante + Select to Next Word Sélectionner jusqu'au mot suivant + Select to Previous Line Sélectionner jusqu'à la ligne précédente + Select to Previous Word Sélectionner jusqu'au mot précédent + Select to Segment End Sélectionner jusqu'à la fin du segment + Select to Segment Start Sélectionner jusqu'au début du segment + Selection Tool Outil Sélection + Set Comparison Reference Définir la référence de comparaison + Set Creative Look Définir le look créatif + Set Input LUT Définir la LUT d'entrée + Set Project Panel View Définir l'affichage du panneau Projet + Show Next Screen Afficher l'écran suivant + Show Previous Screen Afficher l'écran précédent + Show Program Transcript Afficher la transcription du programme + Shuttle Left Navette vers la gauche + Shuttle Right Navette vers la droite + Shuttle Slow Left Navette lente vers la gauche + Shuttle Slow Right Navette lente vers la droite + Shuttle Stop Arrêter la navette + Slide Clip Selection Left Five Frames Coulisser la sélection de clips de cinq images vers la gauche + Slide Clip Selection Left One Frame Coulisser la sélection de clips d'une image vers la gauche + Slide Clip Selection Right Five Frames Coulisser la sélection de clips de cinq images vers la droite + Slide Clip Selection Right One Frame Coulisser la sélection de clips d'une image vers la droite + Slide Tool Outil Coulissement + Slip Clip Selection Left Five Frames Glisser la sélection de clips de cinq images vers la gauche + Slip Clip Selection Left One Frame Glisser la sélection de clips d'une image vers la gauche + Slip Clip Selection Right Five Frames Glisser la sélection de clips de cinq images vers la droite + Slip Clip Selection Right One Frame Glisser la sélection de clips d'une image vers la droite + Slip Tool Outil Glissement + Social Vertical 1080×1920 Vertical réseaux sociaux 1080×1920 + Soft Vignette Vignettage doux + Source In/Out Entrée/sortie source + Split Caption Scinder le sous-titre + Square Pixels (1.0) Pixels carrés (1.0) + Stack Clips Empiler les clips + Start Export Queue Démarrer la file d'attente d'exportation + Start Voice-over Recording Démarrer l'enregistrement de la voix off + Step Back Five Frames Reculer de cinq images + Step Back One Frame Reculer d'une image + Step Forward Five Frames Avancer de cinq images + Step Forward One Frame Avancer d'une image + Stop Export Queue Arrêter la file d'attente d'exportation + Stop Multi-Camera Recording Arrêter l'enregistrement multicaméra + Stop Voice-over Recording Arrêter l'enregistrement de la voix off + Sync Voice-over Capture Synchroniser la capture de la voix off + TIFF TIFF + TIFF Sequence Séquence TIFF + TextPanel Panneau Texte + Thumbnail Size Next Taille de vignette suivante + Thumbnail Size Previous Taille de vignette précédente + Toggle All Audio Targets Activer/désactiver toutes les cibles audio + Toggle All Source Audio Activer/désactiver tout l'audio source + Toggle All Source Video Activer/désactiver toute la vidéo source + Toggle All Video Targets Activer/désactiver toutes les cibles vidéo + Toggle Audio During Scrubbing Activer/désactiver l'audio pendant le défilement + Toggle Full Screen Activer/désactiver le plein écran + Toggle Mute for All Targeted Audio Tracks Activer/désactiver la coupure du son de toutes les pistes audio ciblées + Toggle Solo for All Targeted Audio Tracks Activer/désactiver le solo de toutes les pistes audio ciblées + Toggle Source/Program Monitor Focus Basculer entre moniteur source et moniteur programme + Toggle Target Audio 1 Activer/désactiver la cible audio 1 + Toggle Target Audio 2 Activer/désactiver la cible audio 2 + Toggle Target Audio 3 Activer/désactiver la cible audio 3 + Toggle Target Audio 4 Activer/désactiver la cible audio 4 + Toggle Target Audio 5 Activer/désactiver la cible audio 5 + Toggle Target Audio 6 Activer/désactiver la cible audio 6 + Toggle Target Audio 7 Activer/désactiver la cible audio 7 + Toggle Target Audio 8 Activer/désactiver la cible audio 8 + Toggle Target Video 1 Activer/désactiver la cible vidéo 1 + Toggle Target Video 2 Activer/désactiver la cible vidéo 2 + Toggle Target Video 3 Activer/désactiver la cible vidéo 3 + Toggle Target Video 4 Activer/désactiver la cible vidéo 4 + Toggle Target Video 5 Activer/désactiver la cible vidéo 5 + Toggle Target Video 6 Activer/désactiver la cible vidéo 6 + Toggle Target Video 7 Activer/désactiver la cible vidéo 7 + Toggle Target Video 8 Activer/désactiver la cible vidéo 8 + Toggle Track Output for All Targeted Video Tracks Activer/désactiver la sortie de toutes les pistes vidéo ciblées + Toggle Trim Type Basculer le type de rognage + Toggle View Basculer l'affichage + Track Select Backward Tool Outil Sélection de piste en arrière + Track Select Forward Tool Outil Sélection de piste en avant + Track Selected Mask Suivre le masque sélectionné + Trim Next Edit to Playhead Rogner le point de montage suivant jusqu'à la tête de lecture + Trim Previous Edit to Playhead Rogner le point de montage précédent jusqu'à la tête de lecture + Trimming Rognage + Type Tool Outil Texte + Unstack Clips Désempiler les clips + Upper First Trame supérieure d'abord + VBR, 1 pass VBR, 1 passe + VBR, 2 pass VBR, 2 passes + CRF (constant quality) CRF (qualité constante) + Vimeo 1080p Full HD Vimeo 1080p Full HD + Vimeo 2160p 4K Ultra HD Vimeo 2160p 4K Ultra HD + Voice-over Record Enregistrement de voix off + Voiceover Voix off + WAV, stereo 48 kHz 16-bit PCM WAV, stéréo 48 kHz PCM 16 bits + WAV, stereo 48 kHz 24-bit PCM WAV, stéréo 48 kHz PCM 24 bits + Warm Tint Dominante chaude + Warm highlights, brown shadows Tons clairs chauds, tons foncés bruns + Waveform Audio Waveform Audio + Waveform Audio 48 kHz 16-bit Waveform Audio 48 kHz 16 bits + Waveform Audio 48 kHz 24-bit Waveform Audio 48 kHz 24 bits + Web & Social Web et réseaux sociaux + Work Area Zone de travail + Write Automation Écrire l'automatisation + YouTube 1080p Full HD YouTube 1080p Full HD + YouTube 2160p 4K Ultra HD YouTube 2160p 4K Ultra HD + Zoom Program Monitor to 100% Zoomer le moniteur programme à 100 % + Zoom Program Monitor to Fit Adapter le moniteur programme à la fenêtre + Zoom Source Monitor to 100% Zoomer le moniteur source à 100 % + Zoom Source Monitor to Fit Adapter le moniteur source à la fenêtre + Zoom Tool Outil Zoom + with: par : + Interface Language Langue de l'interface + System Language Langue du système + Cinematic Cinématographique + Film Emulation Émulation de pellicule + Technical Technique + Teal Shadows, Warm Skin Tons foncés bleu canard, carnations chaudes + Night Exterior Extérieur nuit + Golden Dusk Crépuscule doré + Steel Blue Bleu acier + Hard Sun Soleil dur + Faded Print Tirage délavé + Silver Retention Rétention d'argent + Cross Processed Traitement croisé + Warm Negative Négatif chaud + Neutral Mono Monochrome neutre + Hard Mono Monochrome contrasté + Sepia Tone Virage sépia + Cold Mono Monochrome froid + Lift Shadows Relever les tons foncés + Protect Highlights Protéger les tons clairs + Half Saturation Saturation à 50 % + Boost Vibrance Augmenter la vibrance + Legal Range Squeeze Compression en niveaux légaux + A boxed label with a round pointer; move the label and the pointer follows. Un texte encadré avec un pointeur rond ; déplacez le texte et le pointeur suit. + A rounded tag that fits its text, with a status dot. Une étiquette arrondie qui s'adapte à son texte, avec un point d'état. + A title on a rounded card with a soft shadow. Un titre sur une carte arrondie avec une ombre portée douce. + Accent Color Couleur d'accentuation + Bar Color Couleur de la barre + Box Color Couleur de l'encadré + Box Opacity Opacité de l'encadré + Callout – Pointer Légende – Pointeur + Callout – Tag Légende – Étiquette + Callouts Légendes + Credits Générique + Dot Color Couleur du point + Heading En-tête + Large centred title, accent rule and subtitle. Grand titre centré, filet d'accentuation et sous-titre. + Lower Third – Rule Tiers inférieur – Filet + Lower Third – Slab Tiers inférieur – Bloc + Lower Thirds Tiers inférieurs + Name Color Couleur du nom + Name Size Taille du nom + Name and role on a coloured slab that widens with the name. Nom et fonction sur un bloc coloré qui s'élargit avec le nom. + Name over a thin rule that always matches the name's width. Nom sur un filet fin qui épouse toujours la largeur du nom. + Pointer Color Couleur du pointeur + Role Fonction + Rule Color Couleur du filet + Show Dot Afficher le point + Show Role Afficher la fonction + Slab Color Couleur du bloc + Tag Color Couleur de l'étiquette + Title Color Couleur du titre + Title Size Taille du titre + Title – Boxed Titre – Encadré + Title – Centered Titre – Centré + Titles Titres + Select an item to preview it Sélectionnez un élément pour le prévisualiser + End Credits Générique de fin + End Credits – Roll Générique de fin – Défilement vertical + Ticker – Crawl Bandeau d'infos – Défilement horizontal + Centred credits that roll up from below the frame and leave at the top. Générique centré qui monte depuis le bas de l'image et sort par le haut. + A news-style line that crawls in from the right and leaves at the left. Une ligne façon journal télévisé qui entre par la droite et sort par la gauche. + out sortie + in entrée + The language changed but could not be saved: {e} La langue a été changée mais n'a pas pu être enregistrée : {e} + no Chinese font is installed on this system; the interface stays in English aucune police chinoise n'est installée sur ce système ; l'interface reste en anglais + no Japanese font is installed on this system; the interface stays in English aucune police japonaise n'est installée sur ce système ; l'interface reste en anglais + Show Afficher + Sort Trier + Reverse the order Inverser l'ordre + Grab bag Pêle-mêle + Table Tableau + No contributor data was built into this copy. Aucune donnée de contributeurs n'est intégrée à cette copie. + No model credits were built into this copy. Aucun crédit de modèle n'est intégré à cette copie. + Company Société + Model Modèle + Version Version + Commits Commits + % of all commits % de tous les commits + Lines +/− Lignes +/− + Audio output failed: playing without sound (check Settings ▸ Audio Hardware) Échec de la sortie audio : lecture sans son (vérifiez Préférences ▸ Matériel audio) + Audio output is not responding: playing without sound (check Settings ▸ Audio Hardware) La sortie audio ne répond pas : lecture sans son (vérifiez Préférences ▸ Matériel audio) + Clip Speed / Duration Vitesse/durée du clip + Speed: Vitesse : + Reverse Speed Vitesse inversée + Ripple Edit, Shifting Trailing Clips Montage avec décalage, déplacement des clips suivants + About À propos + Contributors Contributeurs + Models Modèles + Main (8-bit) Main (8 bits) + Main 10 (10-bit, HDR) Main 10 (10 bits, HDR) + Main (8-bit; Main 10 for HDR sequences) Main (8 bits ; Main 10 pour les séquences HDR) + Encoder Encodeur + Hardware (H.265 has no software encoder) Matériel (H.265 n'a pas d'encodeur logiciel) + Hardware (AV1 has no software encoder) Matériel (AV1 n'a pas d'encodeur logiciel) + Text Properties Propriétés du texte + Text Box Height Hauteur de la zone de texte + Paragraph Text Texte de paragraphe + Point Text Texte ponctuel + Text Layer Type Type de calque de texte + Text Styling Style du texte + New Color Matte Nouveau cache couleur + Color Matte Color Couleur du cache couleur + Color: Couleur : + · {time} left · reste {time} + Discard take Rejeter la prise + Voice-over take discarded Prise de voix off rejetée + Binary assets added Ressources binaires ajoutées + Binary assets removed Ressources binaires supprimées + Deselect All Project Items Désélectionner tous les éléments du projet + Display name Nom affiché + First commit Premier commit + H.265 (HEVC) H.265 (HEVC) + Last commit Dernier commit + Line delta Delta de lignes + Lines added Lignes ajoutées + Lines deleted Lignes supprimées + Merged PRs PR fusionnées + Name (A–Z) Nom (A–Z) + Real name Nom réel + Select All Project Items Sélectionner tous les éléments du projet + Username Nom d'utilisateur + Vertical Type Tool Outil Texte vertical + Go to previous keyframe Aller à l'image clé précédente + Go to next keyframe Aller à l'image clé suivante + Add keyframe Ajouter une image clé + Remove keyframe Supprimer l'image clé + Turn on animation and add keyframe Activer l'animation et ajouter une image clé + Click to select, or click in empty space and drag to marquee select. Use Shift, Alt, and Ctrl for other options. Cliquez pour sélectionner, ou cliquez dans un espace vide et faites glisser pour sélectionner par rectangle. Utilisez Maj, Alt et Ctrl pour d'autres options. + Click to zoom in; Alt-click to zoom out. Cliquez pour un zoom avant ; Alt+clic pour un zoom arrière. + Drag: move · Alt-click: smooth/corner · Ctrl-click: delete Glisser : déplacer · Alt+clic : lisse/angle · Ctrl+clic : supprimer + Include Alpha Channel Inclure la couche alpha + Sequence Settings Paramètres de séquence + Color Management Gestion des couleurs + VR Properties Propriétés VR + Editing Mode: Mode de montage : + Timebase: Base de temps : + Frame Size: Taille de l'image : + Pixel Aspect Ratio: Rapport L/H des pixels : + Fields: Trames : + Channel Format: Format des canaux : + Number of Channels: Nombre de canaux : + Preview File Format: Format des fichiers de prévisualisation : + Working Color Space: Espace colorimétrique de travail : + Projection: Projection : + Layout: Disposition : + Captured View: Vue capturée : + Video Previews Prévisualisations vidéo + No Fields (Progressive Scan) Aucune trame (balayage progressif) + Custom ({ratio}) Personnalisé ({ratio}) + {rate} frames/second {rate} images/seconde + {rate} fps Drop-Frame Timecode Timecode drop frame {rate} fps + {rate} fps Non Drop-Frame Timecode Timecode non-drop frame {rate} fps + {rate} fps Timecode Timecode {rate} fps + horizontal horizontal + vertical vertical + Scale motion effects proportionally when changing frame size Mettre à l'échelle les effets de mouvement proportionnellement lors du changement de taille d'image + Maximum Bit Depth Profondeur de bits maximale + Maximum Render Quality Qualité de rendu maximale + Composite in Linear Color Composer en couleur linéaire + Auto Tone Map Media (HDR and log into SDR) Mappage automatique des tons des médias (HDR et log vers SDR) + Configure… Configurer… + Not supported in FilmCraft yet Pas encore pris en charge dans FilmCraft + FilmCraft always composites in linear light FilmCraft compose toujours en lumière linéaire + Loop marked range Boucler la plage marquée + Play Source Lire la source + Source Play/Stop Lire/arrêter la source + Stop Source Arrêter la source + New Sequence Nouvelle séquence + Tracks Pistes + tracks pistes + Clear Transition Effacer la transition + Set Transition Duration Définir la durée de la transition + Active Actif + Appearance Mode Mode d'apparence + Dark Theme Thème sombre + Light Theme Thème clair + Next Appearance Mode Mode d'apparence suivant + Sync with system Synchroniser avec le système + UI Scale Échelle de l'interface + Auto (system) Auto (système) + In Entrée + Out Sortie + Export Markers as CSV… Exporter les marqueurs au format CSV… + Fill Type Type de remplissage + Gradient Angle Angle du dégradé + Linear Gradient Dégradé linéaire + Solid Uni + GPU Rendering Rendu GPU + Composite on the GPU when available Composer sur le GPU si disponible + Text to Speech Synthèse vocale + Edit Narration Modifier la narration + Project Notes Notes du projet + Get Project Notes Obtenir les notes du projet + Edit Project Notes Modifier les notes du projet + Notes for this project: to-dos, instructions, frame numbers… Saved with the project. Notes pour ce projet : tâches, instructions, numéros d'image… Enregistrées avec le projet. + Use Effect Shutter Angle Utiliser l'angle d'obturation de l'effet + Settings Préférences diff --git a/crates/ui-egui/src/i18n/fr.tsv.attribution b/crates/ui-egui/src/i18n/fr.tsv.attribution new file mode 100644 index 00000000..30609ac7 --- /dev/null +++ b/crates/ui-egui/src/i18n/fr.tsv.attribution @@ -0,0 +1,8 @@ +asset: fr.tsv +title: French interface translation catalog +author: FilmCraft contributors +source: original work +license: MIT OR Apache-2.0 +license-file: LICENSE-MIT and LICENSE-APACHE +added: 2026-10-10 by vangel76 +notes: Original English-to-French interface translations using clean-room NLE vocabulary. diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 1cbf5b05..7225b5c9 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -44,6 +44,7 @@ pub enum Language { ZhCn, De, Ru, + Fr, } static JAPANESE: OnceLock = OnceLock::new(); @@ -53,9 +54,10 @@ static UKRAINIAN: OnceLock = OnceLock::new(); static CHINESE: OnceLock = OnceLock::new(); static GERMAN: OnceLock = OnceLock::new(); static RUSSIAN: OnceLock = OnceLock::new(); +static FRENCH: OnceLock = OnceLock::new(); impl Language { - pub const ALL: [Self; 8] = [Self::En, Self::Ja, Self::Es, Self::PtBr, Self::Uk, Self::ZhCn, Self::De, Self::Ru]; + pub const ALL: [Self; 9] = [Self::En, Self::Ja, Self::Es, Self::PtBr, Self::Uk, Self::ZhCn, Self::De, Self::Ru, Self::Fr]; pub fn name(self) -> &'static str { match self { @@ -67,6 +69,7 @@ impl Language { Self::ZhCn => "简体中文", Self::De => "Deutsch", Self::Ru => "Русский", + Self::Fr => "Français", } } @@ -80,6 +83,7 @@ impl Language { "zh-cn" => Some(Self::ZhCn), "de" => Some(Self::De), "ru" => Some(Self::Ru), + "fr" => Some(Self::Fr), _ => None, } } @@ -87,11 +91,12 @@ impl Language { /// Interface Language ▸ System Language (#218): the first of the user's preferred languages /// (BCP 47 or POSIX locale tags such as `es-419`, `pt_BR.UTF-8`, most preferred first) that the /// interface has, else English. Any Portuguese gets the Brazilian catalog, the only one there is; - /// any German (`de-AT`, `de_CH.UTF-8`…) gets the German one. Chinese gets the Simplified catalog + /// any German (`de-AT`, `de_CH.UTF-8`…) gets the German one and any French (`fr-CA`, `fr_BE.UTF-8`…) + /// the French one. Chinese gets the Simplified catalog /// only for Simplified locales (`zh`, `zh-CN`, `zh-SG`, `zh-Hans…`); Traditional ones (`zh-TW`, /// `zh-HK`, `zh-MO`, `zh-Hant…`) are skipped. pub fn from_locales(tags: &[String]) -> Self { - const PRIMARY: [(&str, Language); 7] = [ + const PRIMARY: [(&str, Language); 8] = [ ("en", Language::En), ("ja", Language::Ja), ("es", Language::Es), @@ -99,6 +104,7 @@ impl Language { ("uk", Language::Uk), ("de", Language::De), ("ru", Language::Ru), + ("fr", Language::Fr), ]; tags.iter() .find_map(|tag| { @@ -127,6 +133,7 @@ impl Language { Self::ZhCn => "zh-cn", Self::De => "de", Self::Ru => "ru", + Self::Fr => "fr", } } @@ -141,6 +148,7 @@ impl Language { Self::ZhCn => Some(CHINESE.get_or_init(|| Catalog::parse(include_str!("zh-cn.tsv")))), Self::De => Some(GERMAN.get_or_init(|| Catalog::parse(include_str!("de.tsv")))), Self::Ru => Some(RUSSIAN.get_or_init(|| Catalog::parse(include_str!("ru.tsv")))), + Self::Fr => Some(FRENCH.get_or_init(|| Catalog::parse(include_str!("fr.tsv")))), } } @@ -310,6 +318,7 @@ mod tests { ("zh-cn", include_str!("zh-cn.tsv")), ("de", include_str!("de.tsv")), ("ru", include_str!("ru.tsv")), + ("fr", include_str!("fr.tsv")), ] { let (entries, errors) = catalog::parse_entries(text); assert!(errors.is_empty(), "{code}: {errors:?}"); @@ -350,6 +359,13 @@ mod tests { 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"); for l in Language::ALL { assert_eq!(Language::parse(l.code()), Some(l)); let json = serde_json::to_string(&l).unwrap(); @@ -801,6 +817,23 @@ mod tests { let mut restarted = filmcraft_engine::Session::default(); restarted.prefs = serde_json::from_str(&prefs).unwrap(); assert_eq!(crate::FilmcraftApp::new(restarted).ui.language, Language::De); + let result = crate::menus::invoke(&mut app, &ctx, "app.language.french", serde_json::json!({})).unwrap(); + assert_eq!(result, serde_json::json!("fr")); + assert_eq!((app.ui.language, current()), (Language::Fr, Language::Fr)); + assert_eq!(app.session.prefs.general.interface_language, "fr"); + for it in crate::menus::menu_items(&app).iter().filter(|it| it.id.starts_with("app.language.")) { + assert_eq!(it.checked, Some(it.id == "app.language.french"), "{}", it.id); + } + 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("app.language.german"), Some("Deutsch")); + let saved = serde_json::to_string(&app.ui).unwrap(); + assert!(saved.contains("\"language\":\"fr\""), "{saved}"); + let prefs = serde_json::to_string(&app.session.prefs).unwrap(); + let mut restarted = filmcraft_engine::Session::default(); + restarted.prefs = serde_json::from_str(&prefs).unwrap(); + assert_eq!(crate::FilmcraftApp::new(restarted).ui.language, Language::Fr); crate::menus::invoke(&mut app, &ctx, "app.language.english", serde_json::json!({})).unwrap(); assert_eq!(app.ui.language, Language::En); set_current(Language::En); @@ -829,11 +862,15 @@ mod tests { for tag in ["de", "DE", "de-DE", "de-AT", "de-CH", "de-LI", "de-LU", "de_DE.UTF-8", "de_AT.UTF-8@euro", "de_CH"] { assert_eq!(l(&[tag]), Language::De, "{tag}"); } - assert_eq!(l(&["fr-FR", "it", "es-MX", "ja"]), Language::Es, "the first one the interface has"); - assert_eq!(l(&["fr-CH", "de-CH", "en"]), Language::De); + for tag in ["fr", "FR", "fr-FR", "fr-CA", "fr-BE", "fr-CH", "fr_FR.UTF-8", "fr_BE.UTF-8@euro", "fr_CA"] { + assert_eq!(l(&[tag]), Language::Fr, "{tag}"); + } + assert_eq!(l(&["it-IT", "nl", "es-MX", "ja"]), Language::Es, "the first one the interface has"); + assert_eq!(l(&["it-CH", "de-CH", "fr-CH", "en"]), Language::De); + assert_eq!(l(&["it-CH", "fr-CH", "de-CH", "en"]), Language::Fr); assert_eq!(l(&["en-GB", "es"]), Language::En); assert_eq!(l(&["en-US", "de"]), Language::En); - for none in [&[][..], &["fr"], &["C"], &["POSIX"], &[""], &["e"], &["esp"], &["-es"], &["deu"], &["d"], &["-de"]] { + for none in [&[][..], &["it"], &["C"], &["POSIX"], &[""], &["e"], &["esp"], &["-es"], &["deu"], &["d"], &["-de"], &["fra"], &["f"], &["-fr"]] { assert_eq!(l(none), Language::En, "{none:?}"); } assert_eq!(l(&[&"x".repeat(1 << 20), "es"]), Language::Es); @@ -856,7 +893,7 @@ mod tests { let count = asked.clone(); app.hooks.system_languages = Some(Box::new(move || { count.set(count.get() + 1); - vec!["fr-FR".into(), "es-ES".into()] + vec!["it-IT".into(), "es-ES".into()] })); app.apply_prefs(&ctx); assert_eq!(app.ui.language, Language::Es); @@ -915,6 +952,36 @@ mod tests { assert_eq!(Language::De.tr("Settings"), "Voreinstellungen"); } + /// 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:?}"); + } + let (portuguese, _) = catalog::parse_entries(include_str!("pt-br.tsv")); + for (_, en, _) in portuguese { + 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); + } + #[test] fn russian_entries_cover_the_original_menu_catalog() { let app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); @@ -933,22 +1000,14 @@ mod tests { } } - #[test] - fn ukrainian_renders_with_bundled_fonts() { + /// 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(); crate::theme::install(&ctx, &crate::theme::Tokens::for_kind(crate::theme::ThemeKind::default())); let mut output = ctx.run_ui(egui::RawInput::default(), |_| {}); output.textures_delta.clear(); - let (entries, _) = catalog::parse_entries(include_str!("uk.tsv")); + let chars: std::collections::BTreeSet<_> = text.filter(|ch| !ch.is_ascii()).collect(); ctx.fonts_mut(|fonts| { - // Include the entire alphabet, even letters not yet used by a translated menu label. - let alphabet = "АБВГҐДЕЄЖЗИІЇЙКЛМНОПРСТУФХЦЧШЩЬЮЯабвгґдеєжзиіїйклмнопрстуфхцчшщьюя"; - let chars: std::collections::BTreeSet<_> = alphabet - .chars() - .chain(Language::Uk.name().chars()) - .chain(entries.iter().flat_map(|(_, _, uk)| uk.chars())) - .filter(|ch| !ch.is_ascii()) - .collect(); for family in crate::theme::font_families() { let font = egui::FontId::new(13.0, family); // has_glyph compares font faces, giving false negatives when Inter also supplies @@ -966,6 +1025,24 @@ mod tests { }); } + #[test] + fn ukrainian_renders_with_bundled_fonts() { + let (entries, _) = catalog::parse_entries(include_str!("uk.tsv")); + // Include the entire alphabet, even letters not yet used by a translated menu label. + let alphabet = "АБВГҐДЕЄЖЗИІЇЙКЛМНОПРСТУФХЦЧШЩЬЮЯабвгґдеєжзиіїйклмнопрстуфхцчшщьюя"; + assert_bundled_fonts_cover(alphabet.chars().chain(Language::Uk.name().chars()).chain(entries.iter().flat_map(|(_, _, uk)| uk.chars()))); + } + + #[test] + fn french_renders_with_bundled_fonts() { + let (entries, _) = catalog::parse_entries(include_str!("fr.tsv")); + // every French letter and quotation mark, even ones no entry uses yet, and every letter the + // entries use (symbols such as ✔ come from the English sources) + let alphabet = "ÀÂÆÇÉÈÊËÎÏÔŒÙÛÜŸàâæçéèêëîïôœùûüÿ«»’"; + let letters = entries.iter().flat_map(|(_, _, fr)| fr.chars()).filter(|ch| ch.is_alphabetic()); + assert_bundled_fonts_cover(alphabet.chars().chain(Language::Fr.name().chars()).chain(letters)); + } + #[test] fn japanese_needs_an_installed_font() { let mut app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index c0095c6e..97ad0f1e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -30,6 +30,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ uic!("app.language.chinese", "简体中文", ["Edit", "Language"], None), uic!("app.language.german", "Deutsch", ["Edit", "Language"], None), uic!("app.language.russian", "Русский", ["Edit", "Language"], None), + uic!("app.language.french", "Français", ["Edit", "Language"], None), uic!("source.playback.toggle", "Source Play/Stop", [], None), uic!("source.playback.play", "Play Source", [], None), uic!("source.playback.stop", "Stop Source", [], None), @@ -184,6 +185,7 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: | "app.language.chinese" | "app.language.german" | "app.language.russian" + | "app.language.french" ) { // Japanese needs the craft-fonts (built with CRAFT_FONTS_DIR) or a font installed on the system if id == "app.language.japanese" && !crate::i18n::install_japanese_font(ctx) { @@ -201,6 +203,7 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: "app.language.chinese" => crate::i18n::Language::ZhCn, "app.language.german" => crate::i18n::Language::De, "app.language.russian" => crate::i18n::Language::Ru, + "app.language.french" => crate::i18n::Language::Fr, _ => crate::i18n::Language::En, }; // The preference is updated in memory before it is written, so a failed write (read-only @@ -625,6 +628,7 @@ pub fn menu_items(app: &FilmcraftApp) -> Vec { "app.language.chinese" => it.checked = Some(app.ui.language == crate::i18n::Language::ZhCn), "app.language.german" => it.checked = Some(app.ui.language == crate::i18n::Language::De), "app.language.russian" => it.checked = Some(app.ui.language == crate::i18n::Language::Ru), + "app.language.french" => it.checked = Some(app.ui.language == crate::i18n::Language::Fr), _ => {} } if it.id.starts_with("view.") { diff --git a/docs/gaps.md b/docs/gaps.md index b479a6d1..b903c559 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -190,10 +190,10 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** ## G14. Localization: 8 of the 12 key languages missing -- **Have:** Spanish, Japanese and Simplified Chinese catalogs cover ~99.6–99.9% of the ~3,500 - cataloged strings; Portuguese (Brazil) and Ukrainian ~10% (menus). +- **Have:** Spanish, Japanese, Simplified Chinese and French (#498) catalogs cover ~99.6–100% of + the ~3,500 cataloged strings; Portuguese (Brazil) and Ukrainian ~10% (menus). - **Missing:** Hindi, Arabic (no RTL interface layout; Arabic titles reported broken, #395), - French (#498), Indonesian, German (#490), Korean (#508; needs Hangul fonts in craft-fonts), + Indonesian, German beyond menus (#490), Korean (#508; needs Hangul fonts in craft-fonts), Vietnamese, Portuguese beyond menus; Premiere also ships Italian and Russian. Engine errors, CLI and MCP stay English. No language has a recorded native-speaker review. - **Estimate:** 150–230 h. Doc: [localization-parity.md](localization-parity.md). diff --git a/docs/localization-parity.md b/docs/localization-parity.md index a4a18386..6124d1c7 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -10,14 +10,15 @@ Portuguese (Brazil), Russian, Simplified Chinese. Edit ▸ Language (also Settings ▸ General, `general.interfaceLanguage`) switches the interface and persists across restarts; System Language (the default) follows the OS's or browser's preferred -languages and falls back to English. Commands `app.language.` +languages and falls back to English. Commands `app.language.` are reachable over the control channel. One catalog per language, `crates/ui-egui/src/i18n/.tsv` (format in the header of `es.tsv`); UI code wraps strings in `tl!("…")` / `tlf!("…{name}", name)`, and registry names (effects and their parameters, settings, commands, panels, workspaces, presets) are translated where they are drawn. Searches match the displayed labels as well as English sources. Tests fail when a `tl!` literal, a menu label or a registry name has no Spanish entry; `zh-cn.tsv` must also cover every `tl!` literal and menu -label; `ja.tsv` has the same entries but is not yet required by tests. Engine error messages, the +label; `ja.tsv` and `fr.tsv` have the same entries but are not yet required by tests (French must +cover the core menus and keep no entry without a Spanish one). Engine error messages, the control channel, the CLI and MCP stay English. Fonts come from [craft-fonts](https://github.com/storytold/craft-fonts) (`CRAFT_FONTS_DIR`, all @@ -41,7 +42,7 @@ whole interface, one PR) at ~4–8 h per language for the catalog plus tests. | Spanish | es | 3,496 (99.9%) | as above | Latin | not recorded | partial | 4–8 h + review | | Hindi | hi | 0 (0%) | — | **no Devanagari shaping in the interface** | — | none | 30–50 h (shaping in UI + catalog + fonts) | | Arabic | ar | 0 (0%) | — | **no RTL layout, no shaping in the interface**; titles broken (#395) | — | none | 40–70 h | -| French | fr | 0 (0%) | — | Latin | — | none (requested #498) | 6–10 h + review | +| 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 | 348 (9.9%) | menus only | Latin | not recorded | menus only | 5–9 h + review | | Indonesian | id | 0 (0%) | — | Latin | — | none | 6–10 h + 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 | @@ -52,9 +53,9 @@ whole interface, one PR) at ~4–8 h per language for the catalog plus tests. Premiere also ships Italian and Russian, which we don't (6–10 h each). -**Localization dimension:** 4 of the 12 key languages near-complete (English, Spanish, Japanese, -Simplified Chinese), 1 menus-only, 7 none; against Premiere's 10 languages we cover 4 well and 1 -partly. **~35%, 150–230 h** for all twelve to `full` plus Italian and Russian, of which the script +**Localization dimension:** 5 of the 12 key languages near-complete (English, Spanish, Japanese, +Simplified Chinese, French), 1 menus-only, 6 none; against Premiere's 10 languages we cover 5 well +and 1 partly. **~35%, 150–230 h** for all twelve to `full` plus Italian and Russian, of which the script work (RTL and complex shaping in the egui interface) is the largest and least parallel part. Every language needs native-speaker review (human). From c9b9bf3aae5c78764dba264f74fd69de59715311 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 00:26:39 +0200 Subject: [PATCH 135/144] #800: MP2 audio in MP4 and MOV decodes instead of playing silent Problem: MP4 and QuickTime name MPEG audio without its layer (ffmpeg stores MP2 as `mp4a` + esds object type 0x6B), and we always made the layer III decoder for it. symphonia's decoder reads only the layer it was made for, so every MP2 frame was rejected and the track played as silence; MOV's `.mp2` sample entry was not recognised at all. Fix: the MPEG audio decoder for MP4 / MOV is made on the first packet, for the layer its frame header names (ISO/IEC 11172-3 2.4.2.3); `.mp2` maps to the MPEG audio codec like `.mp3`; the track is labelled "MPEG Audio" for layers I/II, as Matroska's is. Decoded tracks now match ffmpeg sample-exactly (edit list trims the 481-sample encoder delay). Co-Authored-By: Claude Opus 5.5 --- crates/codecs/src/audio.rs | 74 ++++++++++++++++++++++++++++++- crates/codecs/src/mp4.rs | 13 +++++- crates/codecs/tests/mp4_oracle.rs | 32 +++++++++++++ crates/isobmff/README.md | 1 + crates/isobmff/src/codec.rs | 20 ++++++++- docs/codec-parity.md | 2 +- docs/gaps.md | 10 +++-- 7 files changed, 143 insertions(+), 9 deletions(-) diff --git a/crates/codecs/src/audio.rs b/crates/codecs/src/audio.rs index 63351636..daba8f4c 100644 --- a/crates/codecs/src/audio.rs +++ b/crates/codecs/src/audio.rs @@ -20,6 +20,9 @@ enum Inner { Aac { dec: Box, asc: Vec, up: Option> }, /// AAC whose configuration arrives with the first frame (ADTS / LATM). AacPending, + /// MPEG audio at this rate whose layer arrives with the first frame (MP4 / QuickTime name the + /// codec, not the layer, and symphonia's decoder reads only the layer it was made for). + MpegPending(u32), /// Our own Opus decoder (always 48 kHz output; pre-skip is left to container timestamps). /// `order` maps output channel → decoded channel (Vorbis → WAV/SMPTE order for surround). Opus { dec: Box, order: Option<&'static [usize]> }, @@ -133,6 +136,12 @@ impl PacketDecoder { }; Self::new(codec, sample_rate, None) } + /// MPEG-1/2 audio of a layer the frame headers name (see [`mpeg_audio_layer`]): MP4's + /// `esds` object types 0x69 / 0x6B and QuickTime's `.mp3` / `.mp2` cover layers I–III alike + /// (ffmpeg stores MP2 as `mp4a` + 0x6B). + pub fn lazy_mpeg_audio(sample_rate: u32) -> Self { + Self { inner: Inner::MpegPending(sample_rate), channels: 0 } + } /// AAC configured by [`Self::ensure_aac`] from in-band headers (ADTS, LATM). pub fn lazy_aac() -> Self { Self { inner: Inner::AacPending, channels: 0 } @@ -160,7 +169,7 @@ impl PacketDecoder { use filmcraft_isobmff::CodecConfig as C; match c { C::Aac(a) => Self::aac(&a.asc, if rate > 0 { rate } else { a.sample_rate }), - C::Mp3 => Self::new(CODEC_TYPE_MP3, rate, None), + C::Mp3 => Ok(Self::lazy_mpeg_audio(rate)), C::Ac3 { .. } | C::Eac3 { .. } => Self::ac3(), C::Alac { cookie } => Self::new(CODEC_TYPE_ALAC, rate, Some(cookie.clone())), C::Flac(_) => Self::new(CODEC_TYPE_FLAC, rate, None), @@ -170,6 +179,10 @@ impl PacketDecoder { } /// Decode one packet into planar channels. pub fn decode(&mut self, data: &[u8], ts: u64) -> Result>> { + if let Inner::MpegPending(rate) = self.inner { + let layer = mpeg_audio_layer(data).ok_or_else(|| CodecError::Decode("MPEG audio: no frame header".into()))?; + self.inner = Self::mpeg_audio(layer, rate)?.inner; + } let dec = match &mut self.inner { Inner::Aac { dec, up, .. } => { let mut out = dec.decode(data).map_err(|e| CodecError::Decode(e.to_string()))?; @@ -192,6 +205,7 @@ impl PacketDecoder { } Inner::Symphonia(d) => d, Inner::AacPending => return Err(CodecError::Decode("AAC: no configuration yet".into())), + Inner::MpegPending(_) => return Err(CodecError::Decode("MPEG audio: no frame header".into())), Inner::Ac3(d) => { // a packet may hold several syncframes (MP4 / Matroska); E-AC-3 dependent // substreams and further programs decode to no channels and are skipped @@ -245,11 +259,21 @@ impl PacketDecoder { Inner::Opus { dec, .. } => dec.reset(), Inner::Symphonia(d) => d.reset(), Inner::Ac3(d) => d.reset(), - Inner::AacPending => {} + Inner::AacPending | Inner::MpegPending(_) => {} } } } +/// The layer (1, 2 or 3) named by the MPEG-1/2 audio frame header that starts `frame`: the 12-bit +/// sync word (11 bits for MPEG-2.5), then `ID` and the 2-bit `layer` field, 3 for layer I down to 1 +/// for layer III (ISO/IEC 11172-3 §2.4.2.3). `None` without a sync word or for the reserved layer 0. +pub fn mpeg_audio_layer(frame: &[u8]) -> Option { + match *frame { + [0xFF, b1, ..] if b1 & 0xE0 == 0xE0 && (b1 >> 1) & 3 != 0 => Some(4 - ((b1 >> 1) & 3)), + _ => None, + } +} + /// The output rate of an AAC stream: twice the core rate for HE-AAC (ISO/IEC 14496-3 §1.6.5). /// Explicit signalling names the rate in the `AudioSpecificConfig`; with implicit signalling (an /// AAC-LC config at 24 kHz or less) SBR is only visible as fill-element data in the access units, @@ -1000,3 +1024,49 @@ mod packet_time_tests { assert_eq!(fixed_packet_samples(FixedFrames::Eac3, &[0x0B, 0x77], 48_000), None); } } + +#[cfg(test)] +mod mpeg_audio_tests { + use super::*; + + /// A silent mono 48 kHz MPEG-1 frame of `layer` (2: 64 kbps, 192 bytes; 3: 56 kbps, 168 + /// bytes): header, then all-zero bit allocation / side information. + fn silent_frame(layer: u8) -> Vec { + let (b1, len) = if layer == 2 { (0xFD, 192) } else { (0xFB, 168) }; + let mut f = vec![0xFF, b1, 0x44, 0xC0]; + f.resize(len, 0); + f + } + + #[test] + fn layer_comes_from_the_frame_header() { + assert_eq!(mpeg_audio_layer(&[0xFF, 0xFF, 0x10, 0xC0]), Some(1)); + assert_eq!(mpeg_audio_layer(&silent_frame(2)), Some(2)); + assert_eq!(mpeg_audio_layer(&silent_frame(3)), Some(3)); + // MPEG-2 layer III, MPEG-2.5 layer II + assert_eq!(mpeg_audio_layer(&[0xFF, 0xF3, 0x90, 0x44]), Some(3)); + assert_eq!(mpeg_audio_layer(&[0xFF, 0xE5, 0x90, 0x44]), Some(2)); + // reserved layer, no sync word, too short + for bad in [&[0xFF, 0xF9, 0x44, 0xC0][..], &[0xFF, 0x1D], &[0x00, 0xFD], &[0xFF], &[]] { + assert_eq!(mpeg_audio_layer(bad), None, "{bad:?}"); + } + } + + /// #800: ffmpeg stores MP2 in MP4 as `mp4a` + object type 0x6B, which names no layer. Handing + /// it to the layer III decoder rejected every frame and the track played as silence. + #[test] + fn mp4_mpeg_audio_decodes_the_layer_its_frames_name() { + for layer in [2u8, 3] { + let mut d = PacketDecoder::for_isobmff(&filmcraft_isobmff::CodecConfig::Mp3, 48_000).unwrap(); + // a packet without a frame header is an error, not a crash, and configures nothing + assert!(d.decode(&[0u8; 16], 0).is_err()); + d.reset(); + for k in 0..3u64 { + let out = d.decode(&silent_frame(layer), k * 1152).unwrap_or_else(|e| panic!("layer {layer}: {e}")); + assert_eq!(out.len(), 1, "layer {layer}"); + assert_eq!(out[0].len(), 1152, "layer {layer}"); + assert!(out[0].iter().all(|&s| s == 0.0), "layer {layer}"); + } + } + } +} diff --git a/crates/codecs/src/mp4.rs b/crates/codecs/src/mp4.rs index 4a3a2c53..c7ea9d06 100644 --- a/crates/codecs/src/mp4.rs +++ b/crates/codecs/src/mp4.rs @@ -547,7 +547,18 @@ fn audio_stream_info(t: &filmcraft_isobmff::Track, bytes: &crate::Src) -> Option CodecConfig::Opus(o) => (crate::audio::OPUS_RATE, (o.output_channels as u32).max(1), None), _ => (if ap.sample_rate > 0.0 { ap.sample_rate as u32 } else { t.timescale }, ap.channels.max(1), None), }; - Some(AudioStreamInfo { sample_rate: rate.max(1), channels: ch.max(1), codec: codec_label(&entry.codec), bits_per_sample: bits }) + let codec = match &entry.codec { + // these entries name no layer (ffmpeg stores MP2 as `mp4a` + 0x6B): the first frame does + CodecConfig::Mp3 => { + let head = t.samples.first().and_then(|s| filmcraft_media::reader::read_range(&*bytes.0, s.offset, (s.size as usize).min(4)).ok()); + match head.as_deref().and_then(crate::audio::mpeg_audio_layer) { + Some(1 | 2) => "MPEG Audio".into(), + _ => codec_label(&entry.codec), + } + } + c => codec_label(c), + }; + Some(AudioStreamInfo { sample_rate: rate.max(1), channels: ch.max(1), codec, bits_per_sample: bits }) } fn codec_label(c: &CodecConfig) -> String { diff --git a/crates/codecs/tests/mp4_oracle.rs b/crates/codecs/tests/mp4_oracle.rs index 012424d7..cbb71ba0 100644 --- a/crates/codecs/tests/mp4_oracle.rs +++ b/crates/codecs/tests/mp4_oracle.rs @@ -215,3 +215,35 @@ fn open_gop_leading_pictures_decode_alone() { } } } + +/// #800: MP2 in MP4 (`mp4a` + object type 0x6B, which names no layer) and in MOV (QuickTime +/// `.mp2`) decodes sample-exactly as ffmpeg does: same length, the encoder delay (481 samples) +/// trimmed by the edit list, and the same samples from the first one on, also after a seek. It +/// used to go to the layer III decoder (MP4: silence) or not decode at all (MOV). +#[test] +fn mp2_in_mp4_and_mov_matches_ffmpeg() { + let ff = filmcraft_testkit::require_ffmpeg!(); + for (name, rate) in [("mp2_stereo_224k_48k.mp4", 48_000u32), ("mp2_stereo_224k_44k.mov", 44_100)] { + let input = format!("anoisesrc=d=1:c=pink:r={rate}:a=0.3"); + let Some(f) = fixture(&ff, name, &["-f", "lavfi", "-i", &input, "-ac", "2", "-c:a", "mp2", "-b:a", "224k"]) else { + eprintln!("SKIPPED ({name}): this ffmpeg can't encode it"); + continue; + }; + let src = filmcraft_codecs::open_bytes(name, bytes(&f)).unwrap(); + let a = src.info().audio().cloned().unwrap(); + assert_eq!((a.sample_rate, a.channels, a.codec.as_str()), (rate, 2, "MPEG Audio"), "{name}"); + let want = ffmpeg_audio_f32(&ff, &f, &[]); + let n = want.len() / 2; + assert!(n > rate as usize * 9 / 10, "{name}: ffmpeg decoded {n} samples"); + let worst = |from: usize, len: usize| { + let got = src.audio(from as i64, len, rate).unwrap(); + (0..len).map(|k| (0..2).map(|c| (got.channels[c][k] - want[2 * (from + k) + c]).abs()).fold(0f32, f32::max)).fold(0f32, f32::max) + }; + let level = want.iter().fold(0f32, |m, v| m.max(v.abs())); + assert!(level > 0.1, "{name}: the source is silent ({level})"); + // a seek into the middle first, then the whole track from the start + let (mid, all) = (worst(n / 2 + 77, 3000), worst(0, n)); + println!("{name}: {n} samples, max error mid {mid:.2e}, whole {all:.2e}"); + assert!(mid <= 1e-4 && all <= 1e-4, "{name}: differs from ffmpeg by {mid} (mid) / {all} (whole)"); + } +} diff --git a/crates/isobmff/README.md b/crates/isobmff/README.md index d74ae54a..47c31bb4 100644 --- a/crates/isobmff/README.md +++ b/crates/isobmff/README.md @@ -60,6 +60,7 @@ Boxes with 64-bit sizes and with size 0 (runs to the end of the file) are suppor | `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)` | diff --git a/crates/isobmff/src/codec.rs b/crates/isobmff/src/codec.rs index b9afd53e..6d2ff79b 100644 --- a/crates/isobmff/src/codec.rs +++ b/crates/isobmff/src/codec.rs @@ -181,7 +181,8 @@ pub enum CodecConfig { /// Advanced Professional Video (`apv1` + `apvC`). Apv(ApvConfig), Aac(AacConfig), - /// MPEG-1/2 audio layer III (esds object type 0x69/0x6B, or QuickTime `.mp3`). + /// 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). @@ -883,6 +884,7 @@ fn is_audio_fourcc(f: &[u8; 4]) -> bool { | b"ac-3" | b"ec-3" | b".mp3" + | b".mp2" | b"enca" | b"ulaw" | b"alaw" @@ -1028,7 +1030,7 @@ pub(crate) fn parse_sample_entry(format: FourCc, payload: &[u8], handler: FourCc Some(p) => parse_esds(p)?, None => unknown(), }, - b".mp3" => CodecConfig::Mp3, + 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(), @@ -1643,6 +1645,20 @@ mod tests { 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 [ diff --git a/docs/codec-parity.md b/docs/codec-parity.md index ba0da95b..8fe1ff6b 100644 --- a/docs/codec-parity.md +++ b/docs/codec-parity.md @@ -42,7 +42,7 @@ contents). Percent is ready-for-real-work for that codec: depth, profiles, speed | AAC (LC, HE, LATM) | decode + encode | own decoder (LC, HE core), LATM | own LC encoder | loudness oracle vs ffmpeg | 90% | 3–5 h | | AC-3 | decode (licensed) | own decoder (ATSC A/52) | none | conformance | 80% | — | | E-AC-3 (Dolby Digital Plus) | decode | own decoder (ATSC A/52 Annex E, #647): independent substream, 1–6 blocks, spectral extension; **no** adaptive hybrid transform, enhanced coupling or 7.1 dependent substreams (plays the 5.1 core) | none | SNR vs ffmpeg 65–91 dB (raw, MP4, MOV, Matroska, TS) | 75% | 10–20 h | -| MP3 / MP2 | decode + MP3 encode | decode (symphonia, MPL-2.0; own MP2) | **none** | — | 60% | 10–20 h (MP3 encoder) | +| MP3 / MP2 | decode + MP3 encode | decode (symphonia, MPL-2.0; own MP2) in raw, MP4, MOV (#800), Matroska, TS, PS; **not** MP2 mono above 192 kbit/s or stereo below 64 kbit/s (ffmpeg's mono default; symphonia rejects them) | **none** | MP2 sample-exact vs ffmpeg (raw, MP4, MOV, TS, PS) | 60% | 10–20 h (MP3 encoder) | | Opus | — (not a Premiere import format) | own decoder, RFC 8251 range-exact | none | conformance vectors | ahead | — | | FLAC, ALAC, Vorbis | partial | decode (symphonia); FLAC in MP4 silent (#603) | none | — | 70% | 3–6 h | | WMA | decode on Windows | none | none | — | 0% | 10–20 h | diff --git a/docs/gaps.md b/docs/gaps.md index 56eddd32..4e14ba75 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -155,9 +155,13 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** ## G10. Container and codec gaps on import - **Have:** E-AC-3 (Dolby Digital Plus) in MP4, MOV, Matroska and MPEG-TS (#647): independent - substream, 1–6 blocks per syncframe, spectral extension. -- **Missing:** E-AC-3 adaptive hybrid transform, enhanced coupling and the extra channels of 7.1 - dependent substreams (played as the 5.1 core), AVI (#598; listed as an extension but no + substream, 1–6 blocks per syncframe, spectral extension. MP2 (MPEG audio layer II) in MP4 and + MOV, sample-exact against ffmpeg (#800). +- **Missing:** MP2 at bit rates ISO/IEC 11172-3 disallows: mono above 192 kbit/s (ffmpeg's default + for mono MP2 is 384 kbit/s) and stereo below 64 kbit/s; the bootstrap decoder (symphonia, also + 0.6) rejects every such frame, so the track plays silent: needs our own layer I/II decoder. + E-AC-3 adaptive hybrid transform, enhanced coupling and the extra channels of 7.1 dependent + substreams (played as the 5.1 core), AVI (#598; listed as an extension but no demuxer), WMV / ASF, DV / DVCPRO / DV100, MPEG-4 Part 2, FLAC in MP4 (#603), camera RAW (R3D incl. R3D NE, ARRIRAW, Sony RAW / X-OCN, Canon RAW, BRAW, ProRes RAW; #345, #383), JPEG 2000 / JPEG XS MXF, Cinema DNG; stills: PSD, EXR, DPX, Targa, HEIF / HEIC, Radiance HDR, AI / EPS; From 50ce8b090cb207748a7dd36bbf148a6509f84f87 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 00:32:08 +0200 Subject: [PATCH 136/144] #805: Morph Cut morphs along optical flow instead of cross-dissolving Problem: Morph Cut rendered as a plain Cross Dissolve, so a jump cut in a talking-head shot showed the subject twice at half opacity during the transition, and the GPU plan mixed it as a dissolve too. Fix: Morph Cut warps both frames along the dense optical flow between them (the Lucas-Kanade flow of #780, crates/render/src/flow.rs) and blends, so the subject moves from its place in the outgoing shot to its place in the incoming one; where the frames do not correspond it stays the cross dissolve. Frames inside a Morph Cut are planned whole on the CPU (no longer a GPU-mixable "simple" transition) and cost as a complex transition in the preview estimate. Morph Cut goldens re-blessed. No face tracking yet. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/plan.rs | 2 +- crates/render/src/preview.rs | 2 +- crates/render/src/tests.rs | 26 ++++++++++ crates/render/src/transitions/dissolve.rs | 5 ++ crates/render/src/transitions/goldens.txt | 6 +-- crates/render/src/transitions/legacy.rs | 3 +- crates/render/src/transitions/mod.rs | 4 +- crates/render/src/transitions/tests.rs | 58 +++++++++++++++++++++++ docs/gaps.md | 9 ++-- docs/target-app-parity.md | 2 +- 10 files changed, 103 insertions(+), 14 deletions(-) diff --git a/crates/render/src/plan.rs b/crates/render/src/plan.rs index 92b333cd..4dd0d4d0 100644 --- a/crates/render/src/plan.rs +++ b/crates/render/src/plan.rs @@ -159,7 +159,7 @@ fn cpu_frame(img: crate::Image) -> Arc { } fn simple_transition(id: &str) -> bool { - matches!(id, "cross_dissolve" | "dip_to_black" | "dip_to_white" | "morph_cut") + matches!(id, "cross_dissolve" | "dip_to_black" | "dip_to_white") } /// The standard effects of a media clip if the GPU can draw it (any blend mode, no opacity masks, diff --git a/crates/render/src/preview.rs b/crates/render/src/preview.rs index f113e904..8079502b 100644 --- a/crates/render/src/preview.rs +++ b/crates/render/src/preview.rs @@ -487,7 +487,7 @@ fn estimate_cost(project: &Project, seq: &Sequence, layers: &[Layer]) -> f64 { total += match l { Layer::Item(_, it) => layer_cost(project, it, px, false), Layer::Transition(_, x, a, b) => { - let simple = matches!(x.effect.effect.as_str(), "cross_dissolve" | "dip_to_black" | "dip_to_white" | "non_additive_dissolve" | "morph_cut"); + let simple = matches!(x.effect.effect.as_str(), "cross_dissolve" | "dip_to_black" | "dip_to_white" | "non_additive_dissolve"); let sides: f64 = [a, b].iter().filter_map(|s| s.map(|it| layer_cost(project, it, px, !simple))).sum(); sides + if simple { 1.0 } else { 12.0 * px } } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 31f5edfc..31eed64c 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -250,6 +250,32 @@ fn push_plan_preserves_reference_track_order_and_reverse() { } } +#[test] +fn morph_cut_frames_render_on_the_cpu_with_the_morph() { + // the GPU compositor can only cross-fade layers: a Morph Cut frame is planned whole on the + // CPU, so preview and export show the optical-flow morph, not a dissolve + let (mut p, red, ocean, seq, map) = setup(); + let a = place(&mut p, seq, 0, red, 0, 24); + let b = place(&mut p, seq, 0, ocean, 24, 24); + let rate = FrameRate::FPS_24; + p.sequence_mut(seq).unwrap().video_tracks[0].transitions.push(filmcraft_project::Transition { + id: filmcraft_project::TransitionId(998), + effect: filmcraft_project::find_effect("morph_cut").unwrap().instance(), + start: rate.tick_of(18), + duration: rate.tick_of(12), + from: Some(a), + to: Some(b), + align: Default::default(), + reverse: false, + }); + let opts = RenderOptions { scale: 0.5, ..Default::default() }; + assert!(plan::is_cpu_frame(&p, seq, rate.tick_of(24))); + assert!(!plan::is_cpu_frame(&p, seq, rate.tick_of(10)), "outside the transition the frame stays on the GPU"); + let plan = plan::plan_frame(&p, seq, rate.tick_of(24), opts, &map); + assert!(matches!(plan, plan::FramePlan::Image(_))); + assert_eq!(plan::execute_cpu(&plan).px, render_sequence(&p, seq, rate.tick_of(24), opts, &map).px); +} + /// A project with a 4 s bars-and-tone clip (1 kHz at −20 dBFS) on A1, with extra audio effects. fn tone_with(effects: &[(&str, &[(&str, f64)])]) -> (Project, ItemId, SourceMap) { let mut p = Project::new("fx"); diff --git a/crates/render/src/transitions/dissolve.rs b/crates/render/src/transitions/dissolve.rs index 208a74ee..14756eef 100644 --- a/crates/render/src/transitions/dissolve.rs +++ b/crates/render/src/transitions/dissolve.rs @@ -54,6 +54,11 @@ pub(super) fn apply(id: &str, t: &Tx) -> Option { } }) } + // a jump cut smoothed by morphing: both frames are warped along the dense optical flow + // between them and blended, so the subject moves from its place in the outgoing shot to + // its place in the incoming one instead of showing twice; where the frames do not + // correspond (no consistent motion) it is the cross dissolve + "morph_cut" => crate::flow::interpolate(t.a, t.b, p), "luma_fade" => { let soft = t.frac("softness").clamp(0.005, 1.0) * 0.5; let from_b = t.choice("source") == 1; diff --git a/crates/render/src/transitions/goldens.txt b/crates/render/src/transitions/goldens.txt index 91f56bba..82692a17 100644 --- a/crates/render/src/transitions/goldens.txt +++ b/crates/render/src/transitions/goldens.txt @@ -49,9 +49,9 @@ film_dissolve 0.75 1933591e335936415928335919426e1e426e9fa16e28426e1950821e50824 luma_fade 0.25 5c2320892320b62320e223205c3620893620b63620e236205c4920894920b64920e249205c5c20895c20b65c20e05c22 luma_fade 0.50 472835462f4c4547621a38702540681d438199cb8f0e46951352a80e53b43b81b60d53b60d60d40d60d60d60d60d60d6 luma_fade 0.75 0d39760d3976224f760d39760d46960d46969ad3960d46960d53b60d53b63b81b60d53b60d60d60d60d60d60d60d60d6 -morph_cut 0.25 4929356a2935912e35ad2935493a3d6a3a3daf5d3dad3a3d494c456a4c45975745ad4c45495d4d6a5d4d8b5d4dad5d4d -morph_cut 0.50 352e4b4b2e4b6c394b782e4b353e5b4b3e5ba8855b783e5b354e6b4b4e6b78656b784e6b355e7b4b5e7b615e7b785e7b -morph_cut 0.75 2134602c34604744604234602142782c4278a1ac784242782150902c50905a7390425090215fa82c5fa8375fa8425fa8 +morph_cut 0.25 472a36582c40892d3a563c75483b43683a3e9f5a44684671434c51604d528a685b7f5271465d556c5c44925c3fb9593d +morph_cut 0.50 372e473e31536a364a3f3b763e3d51503e559e7d5b444780324e71484e6f6972803e57a6375e77505d6e6b5c6b9c5557 +morph_cut 0.75 263357273563493f5c2a3a752f406737416e99a27729478d21508e344f88537b9b2e53a5295e98355d9b3560aa495da6 mosaic_transition 0.25 7f3633973633cb3633f336337f3633973633cb3633f336337f3633973633cb3633f3363359650d70650da4650dcd650d mosaic_transition 0.50 0d4a9f0d4a9fcc3b20cc3b200d4a9f0d4a9fcc3b20cc3b200d4a9f0d4a9fcc3b20cc3b2036566f36566f774e77774e77 mosaic_transition 0.75 0d46963d7696a6df964680960d46963d7696a6df964680960d46963d7696a6df964680960d66e50d66e50d66e50d66e5 diff --git a/crates/render/src/transitions/legacy.rs b/crates/render/src/transitions/legacy.rs index 7af9bb24..1cf8ddfd 100644 --- a/crates/render/src/transitions/legacy.rs +++ b/crates/render/src/transitions/legacy.rs @@ -56,7 +56,6 @@ pub(super) fn apply(e: &EffectInstance, a: &Image, b: &Image, p: f32) -> Option< id, "cross_dissolve" | "cross_dissolve_legacy" - | "morph_cut" | "film_dissolve" | "film_dissolve_legacy" | "additive_dissolve" @@ -93,7 +92,7 @@ fn legacy(id: &str, e: &EffectInstance, a: &Image, b: &Image, p: f32) -> Image { let aspect = (a.w as f32) / (a.h as f32).max(1.0); let pd = p as f64; match id { - "cross_dissolve" | "morph_cut" => masked(a, b, |_, _| p), + "cross_dissolve" => masked(a, b, |_, _| p), "film_dissolve" => { // blend in a gamma-2.2-like space for a filmic, less-dippy dissolve let mut out = Image::new(a.w, a.h); diff --git a/crates/render/src/transitions/mod.rs b/crates/render/src/transitions/mod.rs index ceb515f2..27c26922 100644 --- a/crates/render/src/transitions/mod.rs +++ b/crates/render/src/transitions/mod.rs @@ -18,8 +18,8 @@ //! //! Immersive (VR) transitions are flat approximations on the equirectangular frame: sampling wraps //! horizontally and the iris uses great-circle distance, but there is no sphere re-projection. -//! Morph Cut is a cross dissolve (no face tracking), and the Smart Tools / Text transitions are -//! procedural stand-ins (no content analysis, no typed text). +//! Morph Cut morphs with dense optical flow ([`crate::flow`]) but has no face tracking, and the +//! Smart Tools / Text transitions are procedural stand-ins (no content analysis, no typed text). use filmcraft_project::{EffectInstance, ParamValue}; use filmcraft_time::Tick; diff --git a/crates/render/src/transitions/tests.rs b/crates/render/src/transitions/tests.rs index b209a782..a096c55f 100644 --- a/crates/render/src/transitions/tests.rs +++ b/crates/render/src/transitions/tests.rs @@ -248,6 +248,64 @@ fn modern_and_legacy_ids_are_distinct_renders() { assert!(mean_abs(&m, &want) < 1e-6); } +/// A jump cut (256×144): a static background with a 64×72 subject whose left edge is at `x0`; +/// flat colours, or a smooth texture on both (the subject's own texture moves with it). +fn jump_cut_frame(x0: f32, textured: bool) -> Image { + let tex = |u: f32, v: f32, k: f32| { + 0.35 + 0.15 * (u * 0.21 * k).sin() * (v * 0.17).cos() + 0.1 * ((u + v) * 0.11 * k).sin() + 0.05 * (u * 0.53 - v * 0.31 * k).cos() + }; + paint(256, 144, |x, y| { + let inside = (x0..x0 + 64.0).contains(&x) && (36.0..108.0).contains(&y); + let l = match (textured, inside) { + (false, true) => 0.95, + (false, false) => 0.3, + (true, true) => 0.4 + tex(x - x0, y, 0.6), + (true, false) => 0.6 * tex(x, y, 1.0), + }; + [l, l * 0.9, l * 0.8, 1.0] + }) +} + +#[test] +fn morph_cut_moves_the_subject_instead_of_double_exposing() { + // the subject jumps 12 px between the shots; at the midpoint Morph Cut shows it once, 6 px + // along, where a cross dissolve shows two half-bright copies + let (a, b) = (jump_cut_frame(96.0, false), jump_cut_frame(108.0, false)); + let want = jump_cut_frame(102.0, false); + let morph = apply(&filmcraft_project::find_effect("morph_cut").unwrap().instance(), &a, &b, 0.5); + let dissolve = apply(&filmcraft_project::find_effect("cross_dissolve").unwrap().instance(), &a, &b, 0.5); + // mean red error down a column through the subject's rows + let column_err = |img: &Image, x: usize| (40..104).map(|y| (img.get(x, y)[0] - want.get(x, y)[0]).abs()).sum::() / 64.0; + // uncovered background, both edges, the middle, background still to be covered + for x in [98usize, 103, 134, 165, 170] { + let (m, d) = (column_err(&morph, x), column_err(&dissolve, x)); + assert!(m < 0.12, "x={x}: morph off by {m} (dissolve {d})"); + } + for x in [98usize, 170] { + assert!(column_err(&dissolve, x) > 0.3, "x={x}: the dissolve ghosts here"); + } + let (e_morph, e_dissolve) = (mean_abs(&morph, &want), mean_abs(&dissolve, &want)); + assert!(e_morph * 10.0 < e_dissolve, "morph {e_morph} vs dissolve {e_dissolve}"); + // the background away from the subject is left alone + for (x, y) in [(8, 8), (40, 70), (220, 130)] { + assert!(morph.get(x, y).iter().zip(a.get(x, y)).all(|(p, q)| (p - q).abs() < 1e-4), "({x}, {y})"); + } +} + +#[test] +fn morph_cut_tracks_textured_subjects_and_follows_the_progress() { + // detail on the subject and behind it: the morph lands much closer than the dissolve, at + // the midpoint and a quarter of the way through + for (p, from, to, at) in [(0.5f32, 96.0f32, 108.0f32, 102.0f32), (0.25, 96.0, 112.0, 100.0)] { + let (a, b) = (jump_cut_frame(from, true), jump_cut_frame(to, true)); + let want = jump_cut_frame(at, true); + let morph = apply(&filmcraft_project::find_effect("morph_cut").unwrap().instance(), &a, &b, p); + let dissolve = a.clone().lerp(&b, p); + let (e_morph, e_dissolve) = (mean_abs(&morph, &want), mean_abs(&dissolve, &want)); + assert!(e_morph * 2.0 < e_dissolve, "p={p}: morph {e_morph} vs dissolve {e_dissolve}"); + } +} + // --------------------------------------------------------------------------------------------- // Golden fingerprints diff --git a/docs/gaps.md b/docs/gaps.md index ab25b8af..628bd5a2 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -227,11 +227,12 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** ## G18. Effects depth: approximations and missing behaviour -- **Missing:** Warp Stabilizer is 2-D (Premiere's is 3-D subspace), Morph Cut approximate, Lumetri - HDR-aware maths, multicam paging beyond 16 angles, redesigned 26.0 mask tools (rounded corners, - constrained lines). +- **Missing:** Warp Stabilizer is 2-D (Premiere's is 3-D subspace), Morph Cut has no face tracking + (and occlusion edges are soft), Lumetri HDR-aware maths, multicam paging beyond 16 angles, + redesigned 26.0 mask tools (rounded corners, constrained lines). - **Have:** Optical Flow time interpolation synthesises motion-compensated in-between frames (dense - coarse-to-fine Lucas–Kanade, CPU; #780); Transform shutter angle motion blur (#415); Ultra Key + coarse-to-fine Lucas–Kanade, CPU; #780); Morph Cut morphs the subject along that optical flow + instead of cross-dissolving (CPU; #805); Transform shutter angle motion blur (#415); Ultra Key Setting applied as a parameter preset (#458). - **Estimate:** 60–110 h. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7703c9a7..e74879f6 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -106,7 +106,7 @@ agent-hours to close the area to Premiere, sequential. | Area | Weight | Breadth | Ready | Hours | Evidence | Detail | |---|---|---|---|---|---|---| | Editing, timeline, trimming, multicam | 20% | ~90% | ~70% | 80–140 | Edit algebra, trim modes, Trim Monitor, multicam with audio sync, Premiere default keyboard (143 commands), Source playback and range dragging (10-08). Open: ripple-delete of a gap by column (#668), Backspace on gaps (#648), clip stretching (#653), deselect on empty space (#683), new tracks by dragging media (#483), Escape cancelling drags (#580), time remapping (#510), effects on a whole track (#623) | [ui-parity.md](ui-parity.md) | -| Effects and transitions | 12% | 100% (measured) | ~60% | 80–140 | Every effect exists; Warp Stabilizer is 2-D, Morph Cut and Auto Reframe approximate; Optical Flow time interpolation is motion compensated on the CPU (#780). 34 effect ids run on the GPU against ~82 of Premiere's ~92 marked Accelerated. No Object Mask (26.0, AI). | [hardware-parity.md](hardware-parity.md) | +| Effects and transitions | 12% | 100% (measured) | ~60% | 80–140 | Every effect exists; Warp Stabilizer is 2-D, Auto Reframe approximate, Morph Cut morphs with optical flow but without face tracking (#805); Optical Flow time interpolation is motion compensated on the CPU (#780). 34 effect ids run on the GPU against ~82 of Premiere's ~92 marked Accelerated. No Object Mask (26.0, AI). | [hardware-parity.md](hardware-parity.md) | | Media import and codecs | 12% | ~75% | ~60% | 150–250 | Own bit-exact decoders for H.264, HEVC, VP9, AV1, ProRes, DNx, APV, MPEG-2, AAC, AC-3, Opus. But H.264 only 8-bit 4:2:0 and HEVC no 4:2:2 in software (most 10-bit cameras, #626); no camera RAW (R3D, ARRIRAW, BRAW, Sony RAW, Canon RAW, ProRes RAW, #345, #383); no E-AC-3 (#647), AVI (#598), WMV, DV, XAVC / P2 metadata; Sony start timecode (#460); WebM black (#432) | [codec-parity.md](codec-parity.md), [file-format-parity.md](file-format-parity.md) | | Audio | 10% | ~88% | ~65% | 50–90 | Mixer graph, automation, all 53 effects, Essential Sound, 5.1, voice-over, Remix, multi-stream container audio (#361). Device and playback reports: crackle / cut-outs (#336, #410, #602, #605, #617); Enhance Speech is DSP, not a model; ALSA fallback fixed (#254) | | | Panels and UI fidelity | 10% | ~88% | ~60% | 120–200 | All main panels. Missing: on-screen transform handles in the Program monitor (#639), Effect Controls keyframe zoom / divider / row lines (#640–#645), docking rearrangement (#493), multi-monitor windows (#466), filmstrip thumbnails (#614), marquee in Project panel (#578) | [ui-parity.md](ui-parity.md) | From fb5863bc8a14d33edf7fcef0bd5ac6f41cc76cb1 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 00:34:28 +0200 Subject: [PATCH 137/144] #806: export OpenEXR image sequences Problem: Premiere offers OpenEXR as an image-sequence export format; FilmCraft could not write EXR at all (docs/gaps.md G13). Fix: a new export format (id exr, "OpenEXR") writing numbered .exr stills, with a preset, the Include Alpha Channel option, the file.exportMedia format list and es/ja/zh-cn strings. The writer in crates/export/src/raster.rs follows the public OpenEXR file layout: version 2 single-part scan-line file, half-float B/G/R(/A) channels, no compression, one scan line per chunk, Rec.709 chromaticities; values are scene-linear (the pipeline's transfer undone) with premultiplied alpha, as EXR defines. No new dependency. ZIP / PIZ / DWA compression stays open. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands.rs | 2 +- crates/engine/src/export_tests.rs | 3 +- crates/export/src/lib.rs | 34 ++++- crates/export/src/presets.rs | 6 + crates/export/src/raster.rs | 227 +++++++++++++++++++++++++++- crates/export/src/settings.rs | 9 +- crates/export/src/settings_tests.rs | 45 +++++- 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 + docs/architecture.md | 2 +- docs/file-format-parity.md | 2 +- docs/gaps.md | 5 +- 13 files changed, 326 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index e3b162fd..72a32cb8 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1285,7 +1285,7 @@ fn build() -> Vec { "Media…", ["File", "Export"], None, - r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|jpg|tga|dpx|gif|wav|aiff"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass|crf"?,"crf":0..51=23,"hardwareEncoding":"off|auto"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"alpha":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq|4444|4444xq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#, + r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|jpg|tga|dpx|exr|gif|wav|aiff"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass|crf"?,"crf":0..51=23,"hardwareEncoding":"off|auto"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"alpha":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq|4444|4444xq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#, has_seq, crate::export_tools::export_media ), diff --git a/crates/engine/src/export_tests.rs b/crates/engine/src/export_tests.rs index 6a8c5d71..b6d65d10 100644 --- a/crates/engine/src/export_tests.rs +++ b/crates/engine/src/export_tests.rs @@ -137,7 +137,7 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() { Some(a) => (a[0].as_u64().unwrap(), a[1].as_u64().unwrap()), None => (1920, 1080), }; - if matches!(fmt.as_str(), "png" | "tiff" | "bmp" | "jpg" | "tga" | "dpx") { + 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"); @@ -151,6 +151,7 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() { "jpg" => "mjpeg", "tga" => "targa", "dpx" => "dpx", + "exr" => "exr", _ => "bmp", }; assert_eq!(v["codec_name"], codec, "{name}"); diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 279384f2..c868e91c 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -6,7 +6,7 @@ //! //! Video encoders implement [`VideoEncoder`]; codec crates register theirs with //! [`register_encoder`] (H.264, ProRes …). Built in: Motion-JPEG (MOV), PNG / TIFF / BMP / JPEG / -//! Targa / DPX sequences, GIF, WAV and AIFF. +//! Targa / DPX / OpenEXR sequences, GIF, WAV and AIFF. //! //! [`ExportSettings`] carries every Export-mode setting (frame size, rate, bitrate encoding, audio //! format, multiplexer, captions, effects, metadata) as serde data; [`presets`] defines the @@ -106,6 +106,9 @@ pub enum Format { /// Numbered DPX stills (SMPTE ST 268, 10-bit RGB, BT.709). #[serde(rename = "dpx", alias = "DpxSequence")] DpxSequence, + /// Numbered OpenEXR stills (half-float scene-linear RGB, RGBA with [`ExportSettings::alpha`]). + #[serde(rename = "exr", alias = "ExrSequence")] + ExrSequence, #[serde(rename = "gif", alias = "Gif")] Gif, #[serde(rename = "wav", alias = "Wav")] @@ -140,6 +143,7 @@ impl Format { "jpg" | "jpgsequence" | "jpegsequence" => Format::JpegSequence, "tga" | "targa" | "tgasequence" | "targasequence" => Format::TgaSequence, "dpx" | "dpxsequence" => Format::DpxSequence, + "exr" | "openexr" | "exrsequence" | "openexrsequence" => Format::ExrSequence, "gif" | "animatedgif" => Format::Gif, "wav" | "waveform" | "waveformaudio" => Format::Wav, "aif" | "aiff" | "aifc" => Format::Aiff, @@ -162,6 +166,7 @@ impl Format { Format::JpegSequence => "jpg", Format::TgaSequence => "tga", Format::DpxSequence => "dpx", + Format::ExrSequence => "exr", Format::Gif => "gif", Format::Wav => "wav", Format::Aiff => "aiff", @@ -179,6 +184,7 @@ impl Format { Format::JpegSequence => "jpg", Format::TgaSequence => "tga", Format::DpxSequence => "dpx", + Format::ExrSequence => "exr", Format::Gif => "gif", Format::Wav => "wav", Format::Aiff => "aif", @@ -200,6 +206,7 @@ impl Format { Format::JpegSequence => "JPEG", Format::TgaSequence => "Targa", Format::DpxSequence => "DPX", + Format::ExrSequence => "OpenEXR", Format::Gif => "Animated GIF", Format::Wav => "Waveform Audio", Format::Aiff => "AIFF", @@ -209,7 +216,16 @@ impl Format { } /// Numbered stills, one per frame ([`image_sequence_path`]). pub fn is_image_sequence(self) -> bool { - matches!(self, Format::PngSequence | Format::TiffSequence | Format::BmpSequence | Format::JpegSequence | Format::TgaSequence | Format::DpxSequence) + matches!( + self, + Format::PngSequence + | Format::TiffSequence + | Format::BmpSequence + | Format::JpegSequence + | Format::TgaSequence + | Format::DpxSequence + | Format::ExrSequence + ) } /// An MXF container format. pub fn is_mxf(self) -> bool { @@ -232,7 +248,7 @@ impl Format { pub fn is_mp4_with(self, mux: Multiplexer) -> bool { self == Format::Av1 || (self.is_h26x() && mux == Multiplexer::Mp4) } - pub const ALL: [Format; 18] = [ + pub const ALL: [Format; 19] = [ Format::H264, Format::Hevc, Format::Av1, @@ -246,6 +262,7 @@ impl Format { Format::JpegSequence, Format::TgaSequence, Format::DpxSequence, + Format::ExrSequence, Format::Gif, Format::Wav, Format::Aiff, @@ -302,9 +319,10 @@ pub struct ExportSettings { /// sequence's HDR space and signal it (VUI / `colr` / `mdcv` / `clli` / SEI). #[serde(default)] pub sdr: bool, - /// Keep the alpha channel in PNG, TIFF and Targa sequences and ProRes 4444 / 4444 XQ QuickTime movies - /// (straight alpha, Premiere's "Include Alpha Channel"). Off, every frame is flattened over - /// black (#160). Other formats ignore it. + /// Keep the alpha channel in PNG, TIFF, Targa and OpenEXR sequences and ProRes 4444 / 4444 XQ + /// QuickTime movies (straight alpha, Premiere's "Include Alpha Channel"; OpenEXR stores it + /// premultiplied, as the format defines). Off, every frame is flattened over black (#160). + /// Other formats ignore it. #[serde(default)] pub alpha: bool, /// Output frame size (None = Match Source: the sequence size times `scale`). @@ -1659,7 +1677,8 @@ pub fn encode_png(rgba: Vec, w: u32, h: u32) -> Result> { } /// Encode one still of an image sequence (also Export Frame). PNG always carries the alpha -/// channel it is given; `alpha` keeps it in TIFF and Targa too, which are otherwise written as RGB. +/// channel it is given; `alpha` keeps it in TIFF, Targa and OpenEXR too, which are otherwise written +/// as RGB. /// JPEG uses the default export quality ([`encode_still_with_quality`] picks another). pub fn encode_still(format: Format, rgba: Vec, w: u32, h: u32, alpha: bool) -> Result> { encode_still_with_quality(format, rgba, w, h, alpha, ExportSettings::default().quality) @@ -1683,6 +1702,7 @@ pub fn encode_still_with_quality(format: Format, rgba: Vec, w: u32, h: u32, } Format::TgaSequence => return raster::tga(&rgba, w, h, alpha), Format::DpxSequence => return raster::dpx(&rgba, w, h), + Format::ExrSequence => return raster::exr(&rgba, w, h, alpha), Format::PngSequence => { image::ImageEncoder::write_image(image::codecs::png::PngEncoder::new(&mut out), &rgba, w, h, image::ExtendedColorType::Rgba8).map_err(enc)? } diff --git a/crates/export/src/presets.rs b/crates/export/src/presets.rs index 2a021d49..3846ce9a 100644 --- a/crates/export/src/presets.rs +++ b/crates/export/src/presets.rs @@ -160,6 +160,12 @@ pub fn builtin_presets() -> Vec { ExportSettings { format: Format::TgaSequence, ..Default::default() }, ), p("DPX Sequence", "Image Sequence", "Numbered DPX stills (10-bit RGB)", ExportSettings { format: Format::DpxSequence, ..Default::default() }), + p( + "OpenEXR Sequence", + "Image Sequence", + "Numbered OpenEXR stills (half float, linear, uncompressed)", + ExportSettings { format: Format::ExrSequence, ..Default::default() }, + ), p( "Animated GIF 640×360", "Animated GIF", diff --git a/crates/export/src/raster.rs b/crates/export/src/raster.rs index 59641319..74ea4081 100644 --- a/crates/export/src/raster.rs +++ b/crates/export/src/raster.rs @@ -1,9 +1,12 @@ -//! Uncompressed still formats written directly from RGBA8 frames: Targa and DPX. +//! Uncompressed still formats written directly from RGBA8 frames: Targa, DPX and OpenEXR. //! //! Targa follows the Truevision TGA File Format Specification 2.0 (image type 2, uncompressed //! true-colour, bottom-left origin, the layout every reader accepts). DPX follows SMPTE ST 268 //! (version 2.0 header): one RGB image element, 10 bits per component packed into 32-bit //! big-endian words (method A, two padding bits at the bottom), BT.709 transfer and colorimetry. +//! OpenEXR follows the public OpenEXR file layout (version 2, single-part scan-line file): half-float +//! B, G, R (and A) channels, no compression, one scan line per chunk, scene-linear Rec.709 values +//! with premultiplied alpha, as EXR readers expect. use crate::{ExportError, Result}; /// `w * h * 4` when `rgba` holds exactly that many bytes. @@ -129,6 +132,120 @@ pub(crate) fn dpx(rgba: &[u8], w: u32, h: u32) -> Result> { Ok(out) } +/// IEEE 754 binary16 bits of `v`, rounded to nearest even (overflow → infinity, NaN → quiet NaN). +fn half(v: f32) -> u16 { + let b = v.to_bits(); + let sign = ((b >> 16) & 0x8000) as u16; + let exp = ((b >> 23) & 0xFF) as i32; + let man = b & 0x7F_FFFF; + if exp == 0xFF { + return sign | 0x7C00 | if man != 0 { 0x200 } else { 0 }; + } + // rounds `m >> shift` to nearest, ties to even (`shift` is 1..=24) + let round = |m: u32, shift: u32| { + let (q, rem, mid) = (m >> shift, m & ((1 << shift) - 1), 1 << (shift - 1)); + if rem > mid || (rem == mid && q & 1 == 1) { q + 1 } else { q } + }; + let e = exp - 127 + 15; + if e >= 0x1F { + sign | 0x7C00 + } else if e <= 0 { + if e < -10 { + return sign; + } + // subnormal half: the 24-bit significand scaled by 2^(e - 14); rounding up may carry + // into the smallest normal, which is the right encoding + sign | round(man | 0x80_0000, (14 - e) as u32) as u16 + } else { + // a carry out of the mantissa bumps the exponent (up to infinity), as it should + sign | round(((e as u32) << 23) | man, 13) as u16 + } +} + +/// An OpenEXR file: half-float scene-linear Rec.709 RGB, or RGBA with `alpha` (premultiplied, as +/// EXR defines alpha; the frame's straight alpha is multiplied in after linearisation). +pub(crate) fn exr(rgba: &[u8], w: u32, h: u32, alpha: bool) -> Result> { + let n = checked_len(rgba, w, h)?; + let (Ok(x_max), Ok(y_max)) = (i32::try_from(w - 1), i32::try_from(h - 1)) else { + return Err(ExportError::Unsupported(format!("an OpenEXR frame of {w}×{h} is too large"))); + }; + // channels in the alphabetical order the file layout requires + let names: &[u8] = if alpha { b"ABGR" } else { b"BGR" }; + let line = (w as usize).checked_mul(names.len() * 2).ok_or_else(|| ExportError::Encode("frame size".into()))?; + let line_size = i32::try_from(line).map_err(|_| ExportError::Unsupported(format!("an OpenEXR frame of {w}×{h} is too large")))?; + + let mut hd = Vec::with_capacity(512); + hd.extend_from_slice(&[0x76, 0x2F, 0x31, 0x01]); // magic number 20000630 + hd.extend_from_slice(&[2, 0, 0, 0]); // version 2, single-part scan-line file, short names + let attr = |hd: &mut Vec, name: &str, ty: &str, value: &[u8]| { + for s in [name, ty] { + hd.extend_from_slice(s.as_bytes()); + hd.push(0); + } + hd.extend_from_slice(&(value.len() as i32).to_le_bytes()); + hd.extend_from_slice(value); + }; + let le = |v: &[f32]| v.iter().flat_map(|f| f.to_le_bytes()).collect::>(); + let mut channels = Vec::new(); + for &c in names { + channels.extend_from_slice(&[c, 0]); + channels.extend_from_slice(&1_i32.to_le_bytes()); // pixel type HALF + channels.extend_from_slice(&[0, 0, 0, 0]); // pLinear 0, reserved + channels.extend_from_slice(&1_i32.to_le_bytes()); // x sampling + channels.extend_from_slice(&1_i32.to_le_bytes()); // y sampling + } + channels.push(0); + let window: Vec = [0, 0, x_max, y_max].iter().flat_map(|v| v.to_le_bytes()).collect(); + attr(&mut hd, "channels", "chlist", &channels); + attr(&mut hd, "chromaticities", "chromaticities", &le(&[0.64, 0.33, 0.30, 0.60, 0.15, 0.06, 0.3127, 0.3290])); + attr(&mut hd, "compression", "compression", &[0]); // NO_COMPRESSION + attr(&mut hd, "dataWindow", "box2i", &window); + attr(&mut hd, "displayWindow", "box2i", &window); + attr(&mut hd, "lineOrder", "lineOrder", &[0]); // INCREASING_Y + attr(&mut hd, "pixelAspectRatio", "float", &le(&[1.0])); + attr(&mut hd, "screenWindowCenter", "v2f", &le(&[0.0, 0.0])); + attr(&mut hd, "screenWindowWidth", "float", &le(&[1.0])); + hd.push(0); // end of header + + // offset table (one chunk per scan line), then the chunks: y, data size, pixel data + let chunk = line + 8; + let table = (h as usize).checked_mul(8); + let body = (h as usize).checked_mul(chunk); + let size = table.zip(body).and_then(|(t, b)| hd.len().checked_add(t)?.checked_add(b)).ok_or_else(|| ExportError::Encode("frame size".into()))?; + let mut out = Vec::with_capacity(size); + out.extend_from_slice(&hd); + let first = (hd.len() + h as usize * 8) as u64; + for y in 0..h as u64 { + out.extend_from_slice(&(first + y * chunk as u64).to_le_bytes()); + } + let lin = filmcraft_color::srgb_u8_to_linear_table(); + let opaque: [u16; 256] = std::array::from_fn(|i| half(lin[i])); + let row = w as usize * 4; + let mut px = vec![0_u16; w as usize * names.len()]; + for (y, src) in rgba[..n].chunks_exact(row).enumerate() { + // planar per line: every pixel of the first channel, then the next channel … + let wn = w as usize; + for (x, &[r, g, b, a]) in src.as_chunks::<4>().0.iter().enumerate() { + let rgb = [b, g, r]; + if alpha { + let k = f32::from(a) / 255.0; + px[x] = half(k); + for (c, &v) in rgb.iter().enumerate() { + px[(c + 1) * wn + x] = if a == 255 { opaque[v as usize] } else { half(lin[v as usize] * k) }; + } + } else { + for (c, &v) in rgb.iter().enumerate() { + px[c * wn + x] = opaque[v as usize]; + } + } + } + out.extend_from_slice(&(y as i32).to_le_bytes()); + out.extend_from_slice(&line_size.to_le_bytes()); + out.extend(px.iter().flat_map(|v| v.to_le_bytes())); + } + Ok(out) +} + #[cfg(test)] mod tests { use super::*; @@ -167,6 +284,111 @@ mod tests { assert_eq!(&f[18..26], &[255, 0, 0, 128, 0, 0, 255, 255]); } + #[test] + fn half_floats_round_to_nearest_even() { + for (v, h) in [ + (0.0, 0x0000), + (-0.0, 0x8000), + (1.0, 0x3C00), + (0.5, 0x3800), + (-2.0, 0xC000), + (65504.0, 0x7BFF), + (65520.0, 0x7C00), // halfway past the largest half: ties to even = infinity + (1.0e9, 0x7C00), // overflow + (6.103_515_6e-5, 0x0400), // smallest normal + (5.960_464_5e-8, 0x0001), // smallest subnormal, 2^-24 + (2.980_232_2e-8, 0x0000), // 2^-25: halfway to zero, ties to even + (1.0e-10, 0x0000), + (1.000_488_3, 0x3C00), // 1 + 2^-11: halfway, ties to even (down) + (1.001_464_8, 0x3C02), // 1 + 3·2^-11: halfway, ties to even (up) + (f32::INFINITY, 0x7C00), + ] { + assert_eq!(half(v), h, "{v}"); + } + assert_eq!(half(f32::NAN) & 0x7E00, 0x7E00); + } + + fn le32(b: &[u8], at: usize) -> u32 { + u32::from_le_bytes(b[at..at + 4].try_into().unwrap()) + } + + /// Where `needle` (an attribute name, NUL-terminated) starts in the header. + fn find(b: &[u8], needle: &[u8]) -> usize { + b.windows(needle.len()).position(|w| w == needle).unwrap() + } + + #[test] + fn exr_layout() { + // 2×1: opaque white, then (255, 0, 0) at alpha 0.5 + let px = [255, 255, 255, 255, 255, 0, 0, 128]; + for alpha in [false, true] { + let f = exr(&px, 2, 1, alpha).unwrap(); + let nch = if alpha { 4 } else { 3 }; + assert_eq!(&f[..8], &[0x76, 0x2F, 0x31, 0x01, 2, 0, 0, 0]); + let ch = find(&f, b"channels\0chlist\0") + 16; + assert_eq!(le32(&f, ch) as usize, nch * 18 + 1); + let names: Vec = (0..nch).map(|i| f[ch + 4 + i * 18]).collect(); + assert_eq!(names, if alpha { b"ABGR".to_vec() } else { b"BGR".to_vec() }); + let dw = find(&f, b"dataWindow\0box2i\0") + 17; + assert_eq!((le32(&f, dw), le32(&f, dw + 4), le32(&f, dw + 8), le32(&f, dw + 12)), (16, 0, 0, 1)); + let cmp = find(&f, b"compression\0compression\0") + 24; + assert_eq!((le32(&f, cmp), f[cmp + 4]), (1, 0)); + // header ends with a NUL; one offset (scan line 0) points at the only chunk + let end = f.len() - (8 + 2 * nch * 2) - 8; + assert_eq!(f[end - 1], 0); + let off = u64::from_le_bytes(f[end..end + 8].try_into().unwrap()) as usize; + assert_eq!(off, end + 8); + assert_eq!((le32(&f, off), le32(&f, off + 4) as usize), (0, 2 * nch * 2)); + let hv = |c: usize, x: usize| u16::from_le_bytes(f[off + 8 + (c * 2 + x) * 2..][..2].try_into().unwrap()); + let (b, g, r) = if alpha { (1, 2, 3) } else { (0, 1, 2) }; + // white is 1.0 everywhere; red is linear 1.0 · alpha (premultiplied) or 1.0 flat + assert_eq!((hv(b, 0), hv(g, 0), hv(r, 0)), (0x3C00, 0x3C00, 0x3C00)); + assert_eq!((hv(b, 1), hv(g, 1)), (0, 0)); + if alpha { + assert_eq!((hv(0, 0), hv(0, 1)), (0x3C00, half(128.0 / 255.0))); + assert_eq!(hv(r, 1), half(128.0 / 255.0)); + } else { + assert_eq!(hv(r, 1), 0x3C00); + } + } + } + + /// ffmpeg (external test oracle) decodes our OpenEXR files to the values we meant: linear + /// light, premultiplied alpha, rows top to bottom. + #[test] + fn exr_decodes_in_ffmpeg() { + let Some(ffmpeg) = filmcraft_testkit::ffmpeg_or_skip("OpenEXR stills") else { return }; + let (w, h) = (5_u32, 3_u32); + let px: Vec = (0..w * h).flat_map(|i| [(i * 17) as u8, (255 - i * 13) as u8, (i * 40 % 256) as u8, [255, 128, 0, 64][i as usize % 4]]).collect(); + let dir = filmcraft_testkit::fixtures::workspace_root().join("target").join("export-tests").join(format!("exr-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let lin = filmcraft_color::srgb_u8_to_linear_table(); + for alpha in [false, true] { + let path = dir.join(format!("f{alpha}.exr")); + std::fs::write(&path, exr(&px, w, h, alpha).unwrap()).unwrap(); + let out = std::process::Command::new(&ffmpeg) + .args(["-v", "error", "-i"]) + .arg(&path) + .args(["-f", "rawvideo", "-pix_fmt", "gbrapf32le", "-"]) + .output() + .unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let n = (w * h) as usize; + assert_eq!(out.stdout.len(), n * 4 * 4); + let plane = |p: usize, i: usize| f32::from_le_bytes(out.stdout[(p * n + i) * 4..][..4].try_into().unwrap()); + for (i, &[r, g, b, a]) in px.as_chunks::<4>().0.iter().enumerate() { + let k = if alpha { f32::from(a) / 255.0 } else { 1.0 }; + // gbrap planes: G, B, R, A + let want = [lin[g as usize] * k, lin[b as usize] * k, lin[r as usize] * k, k]; + for (p, want) in want.into_iter().enumerate() { + let got = plane(p, i); + assert!((got - want).abs() <= 1e-3 + want * 1e-3, "alpha {alpha}, pixel {i}, plane {p}: {got} vs {want}"); + } + } + } + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn hostile_sizes_are_errors() { assert!(dpx(&[0; 7], 2, 1).is_err()); @@ -175,5 +397,8 @@ mod tests { assert!(tga(&[0; 4], u32::MAX, u32::MAX, false).is_err()); assert!(rgb(&[0; 3], 1, 1).is_err()); assert!(dpx(&[0; 4], u32::MAX, u32::MAX).is_err()); + assert!(exr(&[0; 4], u32::MAX, u32::MAX, true).is_err()); + assert!(exr(&[0; 7], 2, 1, false).is_err()); + assert!(exr(&[], 0, 1, false).is_err()); } } diff --git a/crates/export/src/settings.rs b/crates/export/src/settings.rs index 61ee9dbd..162c958a 100644 --- a/crates/export/src/settings.rs +++ b/crates/export/src/settings.rs @@ -378,11 +378,11 @@ impl ExportSettings { if self.format.is_mxf() { self.mxf_video_codec.encoder_format() } else { self.format } } - /// Whether the output can carry an alpha channel ([`ExportSettings::alpha`]): PNG, TIFF and - /// Targa sequences and ProRes 4444 / 4444 XQ QuickTime movies. + /// Whether the output can carry an alpha channel ([`ExportSettings::alpha`]): PNG, TIFF, + /// Targa and OpenEXR sequences and ProRes 4444 / 4444 XQ QuickTime movies. pub fn supports_alpha(&self) -> bool { match self.format { - Format::PngSequence | Format::TiffSequence | Format::TgaSequence => true, + Format::PngSequence | Format::TiffSequence | Format::TgaSequence | Format::ExrSequence => true, Format::ProRes => crate::prores_profile(&self.prores_profile).chroma() == filmcraft_prores::ChromaFormat::Yuv444, _ => false, } @@ -488,6 +488,8 @@ impl ExportSettings { Format::TgaSequence => px * if self.keeps_alpha() { 4.0 } else { 3.0 } * 8.0 * fps, // 10-bit RGB in one 32-bit word per pixel Format::DpxSequence => px * 4.0 * 8.0 * fps, + // two bytes per half-float channel + Format::ExrSequence => px * if self.keeps_alpha() { 8.0 } else { 6.0 } * 8.0 * fps, Format::Gif => px * 0.6 * 8.0 * fps, Format::Wav | Format::Aiff | Format::MxfOp1a | Format::MxfOpAtom => 0.0, }; @@ -571,6 +573,7 @@ impl ExportSettings { Format::Apv => v += &format!(", {}", crate::apv_profile(&self.apv_profile).name()), Format::Mjpeg | Format::JpegSequence => v += &format!(", quality {}", self.quality), Format::DpxSequence => v += ", 10-bit RGB", + Format::ExrSequence => v += ", 16-bit half float, linear", _ => {} } v diff --git a/crates/export/src/settings_tests.rs b/crates/export/src/settings_tests.rs index 8cfb3170..5381a194 100644 --- a/crates/export/src/settings_tests.rs +++ b/crates/export/src/settings_tests.rs @@ -160,6 +160,49 @@ fn jpeg_targa_and_dpx_sequences() { assert!(jpeg_sizes[0] > jpeg_sizes[1], "quality 95 must be larger than quality 20: {jpeg_sizes:?}"); } +/// OpenEXR sequences (#806): numbered like the others, half-float linear light, RGBA with +/// premultiplied alpha when asked; ffprobe / ffmpeg (external test oracles) read every still. +#[test] +fn openexr_sequences() { + let (p, seq, m) = matte([0.0, 0.0, 1.0, 0.5], 64, 36, None); + for alpha in [false, true] { + let dir = Scratch::new(&format!("seq-exr-{alpha}")); + let mut s = ExportSettings { format: Format::ExrSequence, path: dir.path("Shot.exr"), alpha, ..Default::default() }; + s.range = Some(TimeRange::new(Tick::ZERO, FrameRate::FPS_24.tick_of(3))); + assert_eq!(Format::from_name("OpenEXR"), Some(Format::ExrSequence)); + assert!(s.is_image_sequence() && s.supports_alpha() && s.keeps_alpha() == alpha && !s.has_audio()); + let r = export(&p, seq, &s, &m, &Progress::default()).unwrap(); + assert_eq!(r.frames, 3); + let want: Vec = (0..3).map(|i| format!("Shot{i:03}.exr")).collect(); + assert_eq!(files_in(&dir.0), want); + let first = dir.0.join(&want[0]); + let bytes = std::fs::read(&first).unwrap(); + assert_eq!(&bytes[..4], &[0x76, 0x2F, 0x31, 0x01]); + let Some(ffmpeg) = filmcraft_testkit::ffmpeg_or_skip("OpenEXR sequences") else { continue }; + let out = std::process::Command::new(ffmpeg) + .args(["-v", "error", "-i"]) + .arg(&first) + .args(["-f", "rawvideo", "-pix_fmt", "gbrapf32le", "-"]) + .output() + .unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let n = 64 * 36; + assert_eq!(out.stdout.len(), n * 16); + let plane = |p: usize| -> Vec { out.stdout[p * n * 4..(p + 1) * n * 4].as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)).collect() }; + let (g, b, r, a) = (plane(0), plane(1), plane(2), plane(3)); + for i in 0..n { + assert!(g[i].abs() < 1e-3 && r[i].abs() < 1e-3, "pixel {i}: no red or green"); + if alpha { + // pure blue (linear 1.0) at 50 % opacity, premultiplied + assert!((a[i] - 0.5).abs() < 0.01 && (b[i] - a[i]).abs() < 2e-3, "pixel {i}: {} / {}", b[i], a[i]); + } else { + // flattened over black, then linearised: well below the encoded half level + assert!(a[i] == 1.0 && b[i] > 0.1 && b[i] < 0.5, "pixel {i}: {} / {}", b[i], a[i]); + } + } + } +} + /// `alpha` keeps straight alpha in PNG and TIFF sequences; off (the default) flattens over black (#160). #[test] fn image_sequences_keep_alpha_when_asked() { @@ -658,7 +701,7 @@ fn h265_is_a_format_that_needs_a_registered_encoder() { } assert_eq!(Format::from_name(Format::Hevc.id()), Some(Format::Hevc)); assert_eq!((Format::Hevc.extension(), Format::Hevc.label()), ("mp4", "H.265 (HEVC)")); - assert!(Format::ALL.contains(&Format::Hevc) && Format::ALL.len() == 18); + assert!(Format::ALL.contains(&Format::Hevc) && Format::ALL.len() == 19); let s: ExportSettings = serde_json::from_value(serde_json::json!({"format": "hevc"})).unwrap(); assert_eq!(s.format, Format::Hevc); assert_eq!(serde_json::to_value(&s).unwrap()["format"], "hevc"); diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 51d1cd03..e8fd5a32 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3182,6 +3182,9 @@ Numbered JPEG stills (quality 90) Imágenes fijas JPEG numeradas (calidad 90) Numbered Targa stills (24-bit, uncompressed) Imágenes fijas Targa numeradas (24 bits, sin comprimir) Numbered DPX stills (10-bit RGB) Imágenes fijas DPX numeradas (RGB de 10 bits) + OpenEXR OpenEXR + OpenEXR Sequence Secuencia OpenEXR + Numbered OpenEXR stills (half float, linear, uncompressed) Imágenes fijas OpenEXR numeradas (coma flotante de 16 bits, lineal, sin comprimir) Numbered PNG stills at the sequence's frame size Imágenes fijas PNG numeradas al tamaño de fotograma de la secuencia Numbered TIFF stills (RGB, 8-bit) Imágenes fijas TIFF numeradas (RGB, 8 bits) Opacity 0 → 100 % over the first second Opacidad de 0 a 100 % durante el primer segundo diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 79c7d025..defaea9a 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3171,6 +3171,9 @@ Numbered JPEG stills (quality 90) 連番 JPEG 静止画 (品質 90) Numbered Targa stills (24-bit, uncompressed) 連番 Targa 静止画 (24-bit、非圧縮) Numbered DPX stills (10-bit RGB) 連番 DPX 静止画 (10-bit RGB) + OpenEXR OpenEXR + OpenEXR Sequence OpenEXR 連番画像 + Numbered OpenEXR stills (half float, linear, uncompressed) 連番 OpenEXR 静止画 (16-bit 浮動小数点、リニア、非圧縮) Numbered PNG stills at the sequence's frame size シーケンスと同じフレームサイズの連番 PNG 静止画 Numbered TIFF stills (RGB, 8-bit) 連番 TIFF 静止画 (RGB、8-bit) Opacity 0 → 100 % over the first second 最初の 1 秒で不透明度を 0 → 100 % に変化 diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index 16472ef0..21b84306 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -3165,6 +3165,9 @@ Numbered JPEG stills (quality 90) 带编号的 JPEG 静帧(质量 90) Numbered Targa stills (24-bit, uncompressed) 带编号的 Targa 静帧(24 位,未压缩) Numbered DPX stills (10-bit RGB) 带编号的 DPX 静帧(10 位 RGB) + OpenEXR OpenEXR + OpenEXR Sequence OpenEXR 序列 + Numbered OpenEXR stills (half float, linear, uncompressed) 带编号的 OpenEXR 静帧(16 位半精度浮点,线性,未压缩) Numbered PNG stills at the sequence's frame size 带编号的 PNG 静帧(采用序列帧大小) Numbered TIFF stills (RGB, 8-bit) 带编号的 TIFF 静帧(RGB,8 位) Opacity 0 → 100 % over the first second 在最初一秒内不透明度从 0 → 100% diff --git a/docs/architecture.md b/docs/architecture.md index ed1486af..1f83f353 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -667,7 +667,7 @@ file.exportMedia {path, preset?, settings?, format?, range?, …} export.qui | `mxf-op1a` | DNxHR (default), ProRes or H.264 (Annex B, long GOP), `mxfVideoCodec`; PCM 16/24-bit | MXF OP1a (`filmcraft-mxf` writer: frame-wrapped, index with temporal offsets, start timecode) | | `mxf-opatom` | as `mxf-op1a` | Avid-style MXF OP-Atom: clip-wrapped picture at `path`, one mono PCM file per channel (`_A1.mxf` …, `Report::extra_files`) | | `png`, `tiff`, `bmp`, `jpg` | `image` | numbered stills `000.`, `001.` … | -| `tga`, `dpx` | built in (`raster.rs`: uncompressed Targa 24/32-bit; DPX 10-bit RGB, SMPTE ST 268) | numbered stills, as above | +| `tga`, `dpx`, `exr` | built in (`raster.rs`: uncompressed Targa 24/32-bit; DPX 10-bit RGB, SMPTE ST 268; OpenEXR half-float linear RGB(A), uncompressed scan lines) | numbered stills, as above | | `gif`, `wav`, `aiff` | built in / `image` | GIF / RIFF WAVE (`WAVE_FORMAT_EXTENSIBLE` for 5.1) / AIFF (16- or 24-bit PCM) | - **Reproducible.** The same project and settings give the same file on every machine. Frames render diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 58c45cb6..b62d7853 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -55,7 +55,7 @@ only). "Tested" names the evidence. | PNG, JPEG, TIFF, BMP, GIF | read + write | yes | PNG / TIFF / BMP / JPEG sequences, GIF | 90% | | WebP | — | yes | no | ahead | | **PSD** (layers as sequence) | read (layered) | no | — | 0% | -| **OpenEXR** | read + write | no | no | 0% | +| **OpenEXR** | read + write | no | sequences (half float, linear RGB / premultiplied RGBA, uncompressed; #806) | 35% | | **DPX** | read + write | no | sequences (10-bit RGB, BT.709; #779) | 40% | | Targa | read + write | no | sequences (24-bit, 32-bit with alpha; #779) | 40% | | HEIF / HEIC | read | no | — | 0% | diff --git a/docs/gaps.md b/docs/gaps.md index ab25b8af..164ede9b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -187,10 +187,11 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** ## G13. Delivery formats - **Missing:** AV1 export (software or hardware), MPEG-2 / DVD / Blu-ray, DCP, JPEG 2000 MXF, - AS-10 / AS-11, XDCAM HD MXF, P2, OpenEXR sequences, MP3 and AAC-only audio, + AS-10 / AS-11, XDCAM HD MXF, P2, compressed (ZIP / PIZ / DWA) OpenEXR, MP3 and AAC-only audio, ProRes 4444 / XQ in the export UI (#342; the encoder has the profiles), interlaced encoding, smart render, HEVC software encoder (HEVC exists only where a hardware encoder does). -- **Have:** PNG, TIFF, BMP, JPEG, Targa and DPX (10-bit) image sequences (#779). +- **Have:** PNG, TIFF, BMP, JPEG, Targa and DPX (10-bit) image sequences (#779); OpenEXR + sequences (half float, linear, uncompressed; #806). - **Estimate:** 60–110 h. Doc: [file-format-parity.md](file-format-parity.md). ## G14. Localization: 8 of the 12 key languages missing From d4ec47a4a3e6c494bcd6588b055c1bd98c840953 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 09:39:29 +1100 Subject: [PATCH 138/144] Clear leftover Project item selection when making a new bin. New Bin only selected the folder and started rename, so Clear still targeted clips that were selected before. The trash button and Delete then removed those items and left the bin. Clicking a bin already deselects items; creating one now does the same, and Clear drops the rename field so it does not keep editing a bin that is about to go. --- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/panels/project.rs | 9 ++++++++- crates/ui-egui/tests/project_panel_ui.rs | 23 +++++++++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 38ee823c..5f898166 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1411,6 +1411,7 @@ impl FilmcraftApp { } // Clear in the Project panel removes the selected bin when no item is selected (#587). let clear = if self.ui.focused == PanelKind::Project && id == "project.delete" { + self.ui.project_panel.rename = None; crate::panels::project_views::clear_params(&self.session.state.project_selection, self.ui.project_panel.selected_bin) } else { None diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 9aef769e..95932cb1 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -625,7 +625,10 @@ fn footer(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, v: &View, action actions.push(("file.newBin".into(), json!({"parent": parent, "panel": pre}))); } "project.delete" => match crate::panels::project_views::clear_params(&app.session.state.project_selection, app.ui.project_panel.selected_bin) { - Some(p) => actions.push((id.into(), p)), + Some(p) => { + app.ui.project_panel.rename = None; + actions.push((id.into(), p)); + } None => app.ui.status = tl!("Select items to clear").into(), }, _ => actions.push((id.into(), json!({}))), @@ -732,6 +735,7 @@ pub fn panel_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui) -> bool { actions.push(("projectPanel.rename".into(), json!({}))); } if item(ui, "delete", tl!("Delete"), clear.is_some(), Some("Backspace")) { + app.ui.project_panel.rename = None; actions.push(("project.delete".into(), clear.unwrap_or_default())); } ui.separator(); @@ -979,6 +983,9 @@ pub fn new_bin(app: &mut FilmcraftApp, params: &Value) -> Result app.ui.expanded_bins.push(p); } let panel = params.get("panel").and_then(Value::as_str).map_or(shown.prefix, str::to_string); + // Clicking a bin already clears the item selection so Clear targets the bin (#587). New Bin + // must do the same: leftover clips would otherwise steal the trash button and Delete. + let _ = app.session.execute("project.select", json!({"items": []})); app.ui.project_panel.selected_bin = Some(bin); app.ui.project_panel.rename = Some(Rename { item: None, bin: Some(bin), text, panel }); Ok(r) diff --git a/crates/ui-egui/tests/project_panel_ui.rs b/crates/ui-egui/tests/project_panel_ui.rs index d4ee713e..25b9e294 100644 --- a/crates/ui-egui/tests/project_panel_ui.rs +++ b/crates/ui-egui/tests/project_panel_ui.rs @@ -570,6 +570,29 @@ fn new_bin_asks_for_its_name() { assert!(d.app().ui.project_panel.rename.is_none()); } +#[test] +fn new_bin_then_clear_removes_the_bin() { + // leftover Project item selection must not keep Clear from removing the new bin + let mut d = project_driver(); + d.exec("project.view.set", json!({"view": "list"})); + d.click("project.button.new-item"); + d.wait_for("project.newItem.file.newColorMatte"); + d.click("project.newItem.file.newColorMatte"); + d.click("colorMatte.ok"); + let items0 = d.app().session.project.items.len(); + let bins0 = d.app().session.project.root.children.len(); + assert!(!d.app().session.state.project_selection.is_empty()); + d.click("project.button.file.newBin"); + d.frames(2); + let bin = d.app().ui.project_panel.selected_bin.expect("the new bin is selected"); + assert!(d.app().session.state.project_selection.is_empty(), "New Bin clears leftover item selection"); + d.click("project.button.project.delete"); + d.frames(2); + assert!(d.app().session.project.root.find_bin(filmcraft_project::BinId(bin)).is_none(), "Clear removes the new bin"); + assert_eq!(d.app().session.project.root.children.len(), bins0); + assert_eq!(d.app().session.project.items.len(), items0, "clips already in the project stay"); +} + // ------------------------------------------------------------------------------------ Media Browser fn media_dir(tag: &str) -> std::path::PathBuf { From 16493a7f70984699d563be14cc7547433f376f9d Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 00:47:09 +0200 Subject: [PATCH 139/144] #813: masks round their corners (rounded rectangle masks) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: Premiere 26's redesigned mask tools give rectangle masks rounded corners; FilmCraft masks only had sharp corners. Fix: MaskPath::with_rounded_corners replaces each sharp corner between straight edges by a circular fillet (two vertices joined by a cubic arc, handles 4/3·tan(φ/4)·r, shrunk to fit half of each edge; hostile radii and indices are clamped or ignored). masks.add takes `cornerRadius` for a rounded rectangle, and the new masks.roundCorners command rounds an existing mask's corners (or the listed vertices) in one undo step, on every Mask Path keyframe so animated paths keep interpolating. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/masks.rs | 44 +++++++++- crates/engine/src/masks_tests.rs | 63 ++++++++++++++ crates/project/src/mask.rs | 144 ++++++++++++++++++++++++++++++- docs/architecture.md | 3 +- docs/gaps.md | 8 +- 5 files changed, 253 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/masks.rs b/crates/engine/src/masks.rs index 2a53cfdf..ffa5a5a1 100644 --- a/crates/engine/src/masks.rs +++ b/crates/engine/src/masks.rs @@ -38,7 +38,7 @@ pub(crate) fn commands() -> Vec { spec( "masks.add", "Create Mask", - r#"{"clip":id?,"effect":index|id?="opacity","shape":"ellipse"|"polygon"|"bezier","path":[[x,y]…]|{vertices}?,"center":[x,y]?,"size":[w,h]?}"#, + r#"{"clip":id?,"effect":index|id?="opacity","shape":"ellipse"|"polygon"|"rectangle"|"bezier","path":[[x,y]…]|{vertices}?,"center":[x,y]?,"size":[w,h]?,"cornerRadius":px?}"#, add, ), spec("masks.remove", "Delete Mask", r#"{"clip":id?,"effect":index|id?,"mask":n?}"#, remove), @@ -64,6 +64,7 @@ pub(crate) fn commands() -> Vec { track, ), spec("masks.toggleVertexSmooth", "Convert Mask Vertex", r#"{"clip":id?,"effect":index|id?,"mask":n?,"vertex":n}"#, toggle_smooth), + spec("masks.roundCorners", "Round Mask Corners", r#"{"clip":id?,"effect":index|id?,"mask":n?,"radius":px,"vertices":[n]?}"#, round_corners), CommandSpec { id: "masks.select", label: "Select Mask", @@ -212,7 +213,12 @@ fn add(s: &mut Session, p: &Value) -> Result { None => match shape.as_str() { "ellipse" => MaskPath::ellipse(center, size * 0.5), "polygon" | "rectangle" | "rect" | "4-point" => { - MaskPath::rect(center.x - size.x / 2.0, center.y - size.y / 2.0, center.x + size.x / 2.0, center.y + size.y / 2.0) + let (x0, y0, x1, y1) = (center.x - size.x / 2.0, center.y - size.y / 2.0, center.x + size.x / 2.0, center.y + size.y / 2.0); + match f64_p(p, "cornerRadius") { + Some(r) if !r.is_finite() || r < 0.0 => return Err(bad("masks.add", "`cornerRadius` is pixels ≥ 0")), + Some(r) => MaskPath::rounded_rect(x0, y0, x1, y1, r), + None => MaskPath::rect(x0, y0, x1, y1), + } } _ => return Err(bad("masks.add", "a bezier mask needs `path` (pen points)")), }, @@ -454,6 +460,40 @@ fn toggle_smooth(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } +/// Round the mask's sharp corners (Premiere 26's rounded rectangle masks): every corner between +/// two straight edges (or only the listed `vertices`) becomes a circular fillet of `radius` +/// pixels, shrunk to fit half of each edge. The same corners are rounded in every Mask Path +/// keyframe so an animated path keeps interpolating. Returns how many corners were rounded. +fn round_corners(s: &mut Session, p: &Value) -> Result { + const ID: &str = "masks.roundCorners"; + let radius = f64_p(p, "radius").filter(|r| r.is_finite() && *r > 0.0).ok_or_else(|| bad(ID, "need `radius` (pixels, > 0)"))?; + let only: Option> = match p.get("vertices") { + None | Some(Value::Null) => None, + Some(v) => { + let list = v.as_array().ok_or_else(|| bad(ID, "`vertices` is a list of vertex indices"))?; + let mut out = list + .iter() + .map(|i| i.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(ID, "`vertices` is a list of vertex indices"))) + .collect::>>()?; + out.sort_unstable(); + out.dedup(); + Some(out) + } + }; + let rounded = edit_mask(s, p, "Round Mask Corners", move |m, t| { + let mut corners = m.path_at(t).roundable_corners(); + if let Some(only) = &only { + corners.retain(|i| only.binary_search(i).is_ok()); + } + if corners.is_empty() { + return Err(bad(ID, "no sharp corner between straight edges to round")); + } + drop_all(m, t, |path| *path = path.with_rounded_corners(radius, &corners)); + Ok(corners.len()) + })?; + Ok(json!({"corners": rounded})) +} + fn select(s: &mut Session, p: &Value) -> Result { if bool_p(p, "none") == Some(true) || p.is_null() || p.as_object().is_some_and(|o| o.is_empty()) { s.state.selected_mask = None; diff --git a/crates/engine/src/masks_tests.rs b/crates/engine/src/masks_tests.rs index 549a64fc..1fc069a8 100644 --- a/crates/engine/src/masks_tests.rs +++ b/crates/engine/src/masks_tests.rs @@ -317,3 +317,66 @@ fn tracking_runs_in_the_background_and_can_be_cancelled() { assert!(n < 48, "stopped early ({n} keyframes)"); assert!(s.execute("masks.track", json!({"direction": "backward"})).is_err(), "nothing before the first frame"); } + +/// #813: a rectangle mask with rounded corners (created rounded, or rounded afterwards) cuts the +/// clip's corners away while the edges stay where they were; one undo step, every keyframe. +#[test] +fn rounded_rectangle_masks_cut_their_corners() { + let (mut s, clip) = demo(); + // a 960×540 opacity mask in the middle of the 1920×1080 frame, rounded by 200 px + s.execute("masks.add", json!({"clip": clip.0, "shape": "rectangle", "center": [960, 540], "size": [960, 540], "cornerRadius": 200})).unwrap(); + s.execute("masks.set", json!({"feather": 0})).unwrap(); + let list = s.execute("masks.list", json!({})).unwrap(); + assert_eq!(list["masks"][0]["path"]["vertices"].as_array().unwrap().len(), 8); + let img = s.render_program(0.25).unwrap(); + // just inside the rectangle's corner: cut away; middle of an edge and the centre: kept + let corner = px(&img, 0.26, 0.26); + assert!(corner[0] + corner[1] + corner[2] < 1e-4, "rounded corner cut: {corner:?}"); + assert!(px(&img, 0.5, 0.26)[3] > 0.99, "top edge kept"); + assert!(px(&img, 0.5, 0.5)[3] > 0.99, "inside kept"); + + // round an existing 4-point mask: same picture, one undo step back to sharp corners + s.undo(); + s.undo(); + s.execute("masks.add", json!({"clip": clip.0, "shape": "polygon", "center": [960, 540], "size": [960, 540]})).unwrap(); + s.execute("masks.set", json!({"feather": 0})).unwrap(); + let sharp = s.render_program(0.25).unwrap(); + assert!(px(&sharp, 0.26, 0.26)[3] > 0.99, "a sharp corner keeps the clip"); + let r = s.execute("masks.roundCorners", json!({"radius": 200})).unwrap(); + assert_eq!(r["corners"], 4); + assert_eq!(s.render_program(0.25).unwrap().px, img.px, "rounding afterwards = creating rounded"); + // nothing left to round; bad radii are refused without changing the mask + assert!(s.execute("masks.roundCorners", json!({"radius": 50})).is_err()); + for radius in [json!(0), json!(-5), json!("big"), json!(null)] { + assert!(s.execute("masks.roundCorners", json!({"radius": radius})).is_err(), "{radius}"); + } + s.undo(); + assert_eq!(s.render_program(0.25).unwrap().px, sharp.px, "one undo step restores the sharp corners"); +} + +#[test] +fn rounding_an_animated_mask_rounds_every_keyframe_and_only_the_chosen_corners() { + let (mut s, clip) = demo(); + let it = s.active_sequence().unwrap().find_item(clip).unwrap().1.clone(); + s.set_playhead(it.start); + s.execute("masks.add", json!({"effect": "opacity", "shape": "polygon", "center": [400, 400], "size": [200, 200]})).unwrap(); + s.execute("effects.toggleAnimation", json!({"clip": clip.0, "effect": "opacity", "mask": 0, "param": "path"})).unwrap(); + let rate = s.active_sequence().unwrap().settings.frame_rate; + s.set_playhead(it.start + rate.tick_of(10)); + s.execute("masks.translate", json!({"delta": [300, 0]})).unwrap(); + // only vertices 0 and 2 (top-left, bottom-right), out-of-range indices ignored + let r = s.execute("masks.roundCorners", json!({"radius": 30, "vertices": [0, 2, 99]})).unwrap(); + assert_eq!(r["corners"], 2); + let m = &s.active_sequence().unwrap().find_item(clip).unwrap().1.effects.iter().find(|e| e.effect == "opacity").unwrap().masks[0]; + assert_eq!(m.path.keyframes.len(), 2); + for k in &m.path.keyframes { + let filmcraft_project::ParamValue::Path(p) = &k.value else { panic!("path keyframe") }; + assert_eq!(p.len(), 6, "both keyframes rounded the same corners"); + assert_eq!(p.vertices.iter().filter(|v| v.is_corner()).count(), 2, "vertices 1 and 3 stay sharp"); + } + // halfway the path still interpolates (equal vertex counts) + let l = s.execute("masks.list", json!({"time": (it.start + rate.tick_of(5)).0})).unwrap(); + let v = l["masks"][0]["path"]["vertices"].as_array().unwrap(); + assert_eq!(v.len(), 6); + assert!((v[0]["p"][0].as_f64().unwrap() - 450.0).abs() < 1e-6, "{}", v[0]); +} diff --git a/crates/project/src/mask.rs b/crates/project/src/mask.rs index d2266b4d..764a27a5 100644 --- a/crates/project/src/mask.rs +++ b/crates/project/src/mask.rs @@ -1,9 +1,12 @@ -//! Effect masks (Premiere's per-effect masks: ellipse, 4-point polygon, free-draw Bézier). +//! Effect masks (Premiere's per-effect masks: ellipse, 4-point polygon or rounded rectangle, +//! free-draw Bézier). //! //! Every mask is a closed cubic Bézier path in **clip pixel space** (full-resolution source //! pixels, the space of effect point parameters). An ellipse is four smooth vertices with circular -//! tangents and a polygon is vertices with zero-length tangents, so all three tools produce the same -//! [`MaskPath`] and the same rasterizer, keyframe interpolation and tracker apply to each. +//! tangents, a polygon is vertices with zero-length tangents and a rounded corner is a pair of +//! vertices joined by a circular-arc cubic ([`MaskPath::with_rounded_corners`]), so all the tools +//! produce the same [`MaskPath`] and the same rasterizer, keyframe interpolation and tracker apply +//! to each. //! //! A mask has a keyframable *Mask Path* (vertex-wise interpolation when the vertex counts match, //! hold otherwise), *Feather* (falloff width in pixels, centred on the edge), *Opacity* (%), @@ -80,6 +83,86 @@ impl MaskPath { Self::polygon(&[Vec2::new(x0, y0), Vec2::new(x1, y0), Vec2::new(x1, y1), Vec2::new(x0, y1)]) } + /// An axis-aligned rectangle with corners rounded by `radius` (Premiere 26's rounded + /// rectangle mask). `radius` ≤ 0 (or not finite) gives the plain 4-point [`MaskPath::rect`]. + pub fn rounded_rect(x0: f64, y0: f64, x1: f64, y1: f64, radius: f64) -> Self { + let r = Self::rect(x0, y0, x1, y1); + if radius.is_finite() && radius > 0.0 { r.with_rounded_corners(radius, &[0, 1, 2, 3]) } else { r } + } + + /// Indices of the sharp corners [`MaskPath::with_rounded_corners`] can round: corner vertices + /// of a closed path whose two segments are straight lines. + pub fn roundable_corners(&self) -> Vec { + let n = self.vertices.len(); + if !self.closed || n < 3 { + return Vec::new(); + } + (0..n) + .filter(|&i| { + let (Some(prev), Some(v), Some(next)) = (self.vertices.get((i + n - 1) % n), self.vertices.get(i), self.vertices.get((i + 1) % n)) else { + return false; + }; + v.is_corner() && is_zero(&prev.t_out) && is_zero(&next.t_in) + }) + .collect() + } + + /// The path with each listed corner replaced by a circular fillet of `radius`: two vertices on + /// the adjacent edges joined by a cubic arc (handles `4/3·tan(φ/4)·r` for a turn of φ, so a + /// right angle gets the [`KAPPA`] quarter circle). The fillet is shrunk so it never takes more + /// than half of either edge. Every listed roundable corner ([`MaskPath::roundable_corners`]) + /// becomes two vertices, coincident when its geometry is degenerate (a straight or reversing + /// corner, a zero-length edge), so applying the same list to each keyframe of an animated path + /// keeps the vertex counts equal. Other indices (out of range, curved, repeated) are left alone. + pub fn with_rounded_corners(&self, radius: f64, corners: &[usize]) -> Self { + let n = self.vertices.len(); + let mut round = vec![false; n]; + for &i in corners { + if let Some(r) = round.get_mut(i) { + *r = true; + } + } + let roundable = self.roundable_corners(); + round.iter_mut().enumerate().for_each(|(i, r)| *r &= roundable.binary_search(&i).is_ok()); + let radius = if radius.is_finite() { radius.clamp(0.0, 1e7) } else { 0.0 }; + let mut vertices = Vec::with_capacity(n.saturating_mul(2)); + for (i, v) in self.vertices.iter().enumerate() { + if !round.get(i).copied().unwrap_or(false) { + vertices.push(*v); + continue; + } + let (Some(prev), Some(next)) = (self.vertices.get((i + n - 1) % n), self.vertices.get((i + 1) % n)) else { + vertices.push(*v); + continue; + }; + let (a, b) = (v.p - prev.p, next.p - v.p); + let (la, lb) = (a.length(), b.length()); + let fillet = (la > 1e-9 && lb > 1e-9 && radius > 0.0).then(|| (a * (1.0 / la), b * (1.0 / lb))).and_then(|(u, w)| { + // turn angle φ between the incoming and outgoing directions + let phi = (u.x * w.x + u.y * w.y).clamp(-1.0, 1.0).acos(); + if !(1e-6..std::f64::consts::PI - 1e-6).contains(&phi) { + return None; + } + let tan_half = (phi / 2.0).tan(); + let d = (radius * tan_half).min(la / 2.0).min(lb / 2.0); + let r = d / tan_half; + let h = 4.0 / 3.0 * (phi / 4.0).tan() * r; + Some((u, w, d, h)) + }); + match fillet { + Some((u, w, d, h)) => { + vertices.push(MaskVertex { p: v.p - u * d, t_in: Vec2::ZERO, t_out: u * h }); + vertices.push(MaskVertex { p: v.p + w * d, t_in: w * -h, t_out: Vec2::ZERO }); + } + None => { + vertices.push(MaskVertex::corner(v.p)); + vertices.push(MaskVertex::corner(v.p)); + } + } + } + Self { vertices, closed: self.closed } + } + pub fn len(&self) -> usize { self.vertices.len() } @@ -439,4 +522,59 @@ mod tests { let back: Mask = serde_json::from_str(&s).unwrap(); assert_eq!(back, m); } + + #[test] + fn rounded_rect_corners_are_quarter_circles() { + let p = MaskPath::rounded_rect(0.0, 0.0, 100.0, 60.0, 20.0); + assert_eq!(p.len(), 8); + // top-left corner: a quarter circle of radius 20 about (20, 20); the edges stay straight + let pts = p.flatten(0.01); + let mut on_arc = 0; + for q in &pts { + let (cx, cy) = (q.x.clamp(20.0, 80.0), q.y.clamp(20.0, 40.0)); + let d = ((q.x - cx).powi(2) + (q.y - cy).powi(2)).sqrt(); + if (q.x - cx).abs() > 1e-9 && (q.y - cy).abs() > 1e-9 { + on_arc += 1; + assert!((d - 20.0).abs() < 0.03, "{q:?} is {d} from its corner centre"); + } + assert!(q.x >= -1e-9 && q.x <= 100.0 + 1e-9 && q.y >= -1e-9 && q.y <= 60.0 + 1e-9, "{q:?} outside the rectangle"); + } + assert!(on_arc >= 8, "the corners are curved: {on_arc} arc points"); + // the sharp corner itself is cut away + assert!(pts.iter().all(|q| q.x + q.y > 5.0), "the corner at (0, 0) is rounded off"); + // radius 0 is the plain rectangle + assert_eq!(MaskPath::rounded_rect(0.0, 0.0, 10.0, 5.0, 0.0), MaskPath::rect(0.0, 0.0, 10.0, 5.0)); + } + + #[test] + fn rounding_clamps_to_half_an_edge_and_ignores_curves_and_hostile_input() { + // a radius larger than the shape gives a stadium: fillets meet at the edges' midpoints + let p = MaskPath::rounded_rect(0.0, 0.0, 100.0, 40.0, 1e9); + assert_eq!(p.len(), 8); + for v in &p.vertices { + assert!(v.p.x.is_finite() && v.p.y.is_finite()); + } + assert!(p.vertices.iter().any(|v| (v.p - Vec2::new(0.0, 20.0)).length() < 1e-9), "{:?}", p.vertices); + // a triangle: the 60° turns get fillets tangent to both edges, within the triangle + let tri = MaskPath::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(100.0, 0.0), Vec2::new(50.0, 86.6)]); + let r = tri.with_rounded_corners(10.0, &tri.roundable_corners()); + assert_eq!(r.len(), 6); + assert!(r.vertices.iter().all(|v| v.p.y >= -1e-9)); + // an ellipse has no sharp corners; NaN / negative radii and bad indices change nothing harmful + let e = MaskPath::ellipse(Vec2::new(50.0, 50.0), Vec2::new(30.0, 20.0)); + assert!(e.roundable_corners().is_empty()); + assert_eq!(e.with_rounded_corners(10.0, &[0, 1, 2, 3]), e); + let sq = MaskPath::rect(0.0, 0.0, 10.0, 10.0); + assert_eq!(sq.with_rounded_corners(5.0, &[usize::MAX, 99]), sq); + for bad in [f64::NAN, f64::INFINITY, -3.0, 0.0] { + let q = sq.with_rounded_corners(bad, &[0]); + assert_eq!(q.len(), 5, "a listed corner always splits in two"); + assert!(q.vertices.iter().all(|v| v.is_corner() && v.p.x.is_finite())); + } + // a collinear vertex splits into two coincident corners, keeping vertex counts stable + let line = MaskPath::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(5.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(5.0, 5.0)]); + let q = line.with_rounded_corners(2.0, &[1]); + assert_eq!(q.len(), 5); + assert_eq!(q.vertices[1].p, q.vertices[2].p); + } } diff --git a/docs/architecture.md b/docs/architecture.md index ed1486af..8b09b678 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -529,7 +529,8 @@ with different vertex counts hold). Feather, Opacity and Expansion are ordinary - **GPU.** `filmcraft-gpu::GpuMask` evaluates the same coverage and mix in WGSL (compute), tested to agree with the CPU within 3·10⁻⁶. Layers with masks are CPU-rendered images in frame plans. - **Editing.** `masks.*` commands (add / remove / set / moveVertex / translate / addVertex / - removeVertex / toggleVertexSmooth / select / list); keyframe commands take `"mask": n`. The + removeVertex / toggleVertexSmooth / roundCorners / select / list; `masks.add` takes a + `cornerRadius` for a rounded rectangle); keyframe commands take `"mask": n`. The Program monitor overlay drags vertices, Bézier handles, the whole mask and the feather / expansion handles; drags merge into one undo step. - **Tracking** (`render::track`, `masks.track`): Shi–Tomasi features inside the mask, pyramidal diff --git a/docs/gaps.md b/docs/gaps.md index aec13ce2..c5c87ce5 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -228,11 +228,13 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** ## G18. Effects depth: approximations and missing behaviour - **Missing:** Warp Stabilizer is 2-D (Premiere's is 3-D subspace), Morph Cut approximate, Lumetri - HDR-aware maths, multicam paging beyond 16 angles, redesigned 26.0 mask tools (rounded corners, - constrained lines). + HDR-aware maths, multicam paging beyond 16 angles, redesigned 26.0 mask tools (constrained + lines; an on-monitor corner-roundness handle). - **Have:** Optical Flow time interpolation synthesises motion-compensated in-between frames (dense coarse-to-fine Lucas–Kanade, CPU; #780); Transform shutter angle motion blur (#415); Ultra Key - Setting applied as a parameter preset (#458). + Setting applied as a parameter preset (#458); rectangle and polygon masks round their corners + with circular fillets (`masks.add` `cornerRadius`, `masks.roundCorners`, every path keyframe; + #813). - **Estimate:** 60–110 h. ## G19. Speech to text off in release builds From 5f9908905c530d4cd6e217d230f8583287db5504 Mon Sep 17 00:00:00 2001 From: ReReddo <249398616+ReReddo@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:55:26 +0300 Subject: [PATCH 140/144] monitors: the wheel zooms about the pointer, the middle button pans (#807) Zooming a monitor took the zoom dropdown, which zooms about the middle of the picture, and then scrolling or the Hand tool to bring the part you wanted into view; at Fit the wheel did nothing. Now, on the Program and Source monitors: - The mouse wheel (or a pinch) zooms about the pointer, from Fit too, between the dropdown's 10% and 1600%: the point under the pointer stays under it. It zooms as fast as the Timeline's wheel zoom, through the same wheel_input. - A middle-button drag that starts over the picture pans it, whatever the tool; the Hand tool still pans with the left button. - Fit resets zoom and pan, and each monitor keeps its own, as before. Comparison View, which always fits both frames, ignores the wheel. Tests: view_ui (wheel zoom about the pointer, middle-button pan, the 10% floor, Fit, Source untouched). --- crates/ui-egui/src/panels/monitor.rs | 5 +- crates/ui-egui/src/panels/monitor_view.rs | 39 +++++++++++++-- crates/ui-egui/src/panels/timeline.rs | 8 ++-- crates/ui-egui/tests/view_ui.rs | 58 +++++++++++++++++++++++ docs/monitors.md | 2 +- 5 files changed, 100 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/panels/monitor.rs b/crates/ui-egui/src/panels/monitor.rs index a57d0483..36930535 100644 --- a/crates/ui-egui/src/panels/monitor.rs +++ b/crates/ui-egui/src/panels/monitor.rs @@ -287,8 +287,9 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) // Click/drag in the picture: the Hand tool pans a magnified picture, otherwise focus. let pic_resp = ui.interact(pic, egui::Id::new((prefix, "pic")), if show_picture { Sense::click_and_drag() } else { Sense::hover() }); app.auto.add(&format!("{prefix}.picture"), pic.intersect(video_area), "picture"); - if show_picture { - monitor_view::pan_input(app, ui, which, video_area, pic); + // (Comparison View always fits both frames) + if show_picture && !compare { + monitor_view::zoom_pan_input(app, ui, which, video_area, pic, display_w, ppp); } if which == Which::Program && show_picture { eyedropper(app, ui, &pic_resp, pic, target, rate, time); diff --git a/crates/ui-egui/src/panels/monitor_view.rs b/crates/ui-egui/src/panels/monitor_view.rs index 15e6ab6f..501ead7a 100644 --- a/crates/ui-egui/src/panels/monitor_view.rs +++ b/crates/ui-egui/src/panels/monitor_view.rs @@ -1,7 +1,8 @@ //! Monitor view options (the View menu and the monitors' wrench menus): Playback / Paused //! Resolution, High Quality Playback, Display Mode (Composite Video, Alpha, Red, Green, Blue, //! Multi-Camera, Audio Waveform, Comparison View, Video and Audio Waveform Split), Magnification -//! (Fit, 10%…1600%, scroll or Hand-tool panning), rulers, guides (dragged out of the rulers, Add +//! (Fit, 10%…1600%, the wheel zooming about the pointer, middle-button or Hand-tool panning), +//! rulers, guides (dragged out of the rulers, Add //! Guide…, Clear Guides, Lock Guides, templates saved in the user preferences) and Snap in Program //! Monitor (graphic drags snap to guides, the frame edges and centre). //! @@ -354,14 +355,42 @@ pub fn picture_rect(area: Rect, fw: f32, fh: f32, v: &MonitorView, ppp: f32) -> } } -/// Pan a magnified picture: scroll over it, or drag with the Hand tool. -pub fn pan_input(app: &mut FilmcraftApp, ui: &mut egui::Ui, w: Which, area: Rect, pic: Rect) { +/// Zoom and pan the picture `pic` shown in `area`: the wheel (or a pinch) zooms about the pointer, +/// from Fit too, between the zoom menu's 10% and 1600%; a middle-button drag, or a drag with the +/// Hand tool, pans a magnified picture. `fw` is the picture's width in frame pixels. +pub fn zoom_pan_input(app: &mut FilmcraftApp, ui: &mut egui::Ui, w: Which, area: Rect, pic: Rect, fw: f32, ppp: f32) { + let ctx = ui.ctx().clone(); + if ui.rect_contains_pointer(area) + && let Some(p) = ctx.pointer_hover_pos() + { + // as fast as the Timeline's own wheel zoom + let wheel = crate::panels::timeline::wheel_input(&ctx); + let f = if (wheel.pinch - 1.0).abs() > 1e-4 { wheel.pinch } else { (1.0 + wheel.delta.y * 0.01).clamp(0.5, 2.0) }; + if (f - 1.0).abs() > 1e-4 && pic.width() > 0.0 && fw > 0.0 { + let cur = pic.width() * ppp / fw; + let z = (cur * f).clamp(ZOOMS[0].1, ZOOMS[ZOOMS.len() - 1].1); + let k = z / cur; + // the point of the picture under the pointer stays under it + let centre = p - (p - pic.center()) * k; + let max = ((pic.size() * k - area.size()) / 2.0).max(Vec2::ZERO); + let v = view_mut(app, w); + v.zoom = Some(z); + v.pan = [(centre.x - area.center().x).clamp(-max.x, max.x), (centre.y - area.center().y).clamp(-max.y, max.y)]; + return; + } + } if view(app, w).zoom.is_none() { return; } let mut d = Vec2::ZERO; - if ui.rect_contains_pointer(area) { - d += ui.input(|i| i.smooth_scroll_delta); + // a middle-button drag that began over the picture pans it, whatever the tool + let held = egui::Id::new((prefix(w), "middle-pan")); + let (pressed, down, moved) = ui.input(|i| (i.pointer.button_pressed(egui::PointerButton::Middle), i.pointer.middle_down(), i.pointer.delta())); + let panning = down && (ui.data(|m| m.get_temp::(held)).unwrap_or(false) || (pressed && ui.rect_contains_pointer(area))); + ui.data_mut(|m| m.insert_temp(held, panning)); + if panning { + ctx.set_cursor_icon(egui::CursorIcon::Grabbing); + d += moved; } if app.ui.tool == Tool::Hand { let resp = ui.interact(area, egui::Id::new((prefix(w), "pan")), Sense::drag()); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index ca4fab0d..70cde2b6 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1902,11 +1902,11 @@ fn multicam_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, picked: &[&TrackItem } /// What the wheel and the trackpad did this frame. -struct WheelInput { +pub(crate) struct WheelInput { /// Scroll in points (wheel lines and pages converted), positive = content moves right / down. - delta: egui::Vec2, + pub(crate) delta: egui::Vec2, /// Pinch factor (1 = none). - pinch: f32, + pub(crate) pinch: f32, command: bool, alt: bool, shift: bool, @@ -1915,7 +1915,7 @@ struct WheelInput { /// The wheel events of this frame, read from the events themselves: egui turns Cmd + wheel into a /// zoom and Shift + wheel into a sideways scroll before anyone asks, and the Timeline has its own /// meaning for both. The modifiers are those of the events (a script's wheel carries its own). -fn wheel_input(ctx: &egui::Context) -> WheelInput { +pub(crate) fn wheel_input(ctx: &egui::Context) -> WheelInput { ctx.input(|i| { let mut w = WheelInput { delta: egui::Vec2::ZERO, pinch: 1.0, command: false, alt: false, shift: false }; for e in &i.events { diff --git a/crates/ui-egui/tests/view_ui.rs b/crates/ui-egui/tests/view_ui.rs index 1339ad6c..92b34a1f 100644 --- a/crates/ui-egui/tests/view_ui.rs +++ b/crates/ui-egui/tests/view_ui.rs @@ -183,6 +183,64 @@ fn view_menu_resolutions_and_display_modes() { assert_eq!((s["display"].as_str(), s["show_rulers"].as_bool()), (Some("Composite"), Some(true))); } +fn num(v: &Value) -> f64 { + v.as_f64().unwrap_or_else(|| panic!("not a number: {v}")) +} + +/// #807: the wheel over a monitor zooms about the pointer, a middle-button drag pans, Fit resets, +/// and each monitor keeps its own view. +#[test] +fn wheel_zooms_about_the_pointer_and_the_middle_button_pans() { + let mut d = Driver::demo(); + // at 100% the picture is larger than the monitor, so the element is the monitor's area + d.menu("view.magnification.100"); + let area = d.rect("program.picture"); + let centre = (area[0] + area[2] / 2.0, area[1] + area[3] / 2.0); + let at = (area[0] + area[2] * 0.25, area[1] + area[3] * 0.4); + // 50 points up: 1.5 times; the point under the pointer stays under it + d.ok("ui.scroll", json!({"x": at.0, "y": at.1, "dx": 0.0, "dy": 50.0})); + d.frames(2); + let p = d.program(); + assert!((num(&p["zoom"]) - 1.5).abs() < 1e-4, "{p}"); + let want = ((centre.0 - at.0) * 0.5, (centre.1 - at.1) * 0.5); + assert!((num(&p["pan"][0]) - want.0).abs() < 1.0 && (num(&p["pan"][1]) - want.1).abs() < 1.0, "pan {} for {want:?}", p["pan"]); + + // the middle button drags the picture, whatever the tool + let (zoom, pan) = (num(&p["zoom"]), [num(&p["pan"][0]), num(&p["pan"][1])]); + let send = |d: &mut Driver, e: egui::Event| { + d.harness.input_mut().events.push(e); + d.frames(1); + }; + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Middle, pressed, modifiers: Default::default() }; + let from = egui::pos2(centre.0 as f32, centre.1 as f32); + send(&mut d, egui::Event::PointerMoved(from)); + send(&mut d, button(from, true)); + for k in 1..=4 { + send(&mut d, egui::Event::PointerMoved(from + egui::vec2(-10.0 * k as f32, 5.0 * k as f32))); + } + send(&mut d, button(from + egui::vec2(-40.0, 20.0), false)); + let p = d.program(); + assert_eq!(num(&p["zoom"]), zoom, "a pan does not zoom"); + assert!((num(&p["pan"][0]) - (pan[0] - 40.0)).abs() < 1.0 && (num(&p["pan"][1]) - (pan[1] + 20.0)).abs() < 1.0, "pan {} after {pan:?}", p["pan"]); + + // zooming out stops at the menu's smallest level; Fit resets zoom and pan + for _ in 0..12 { + d.ok("ui.scroll", json!({"x": centre.0, "y": centre.1, "dx": 0.0, "dy": -50.0})); + } + d.frames(2); + assert!((num(&d.program()["zoom"]) - 0.1).abs() < 1e-6, "stops at 10%"); + d.menu("view.magnification.fit"); + let p = d.program(); + assert!(p["zoom"].is_null() && p["pan"] == json!([0.0, 0.0]), "{p}"); + + // from Fit, the wheel zooms in too; the Source monitor keeps its own view + d.ok("ui.scroll", json!({"x": centre.0, "y": centre.1, "dx": 0.0, "dy": 20.0})); + d.frames(2); + assert!(d.program()["zoom"].as_f64().is_some(), "zoomed in from Fit"); + let source = d.ok("ui.inspect", json!({}))["ui"]["source"].clone(); + assert!(source["zoom"].is_null() && source["pan"] == json!([0.0, 0.0]), "{source}"); +} + #[test] fn magnification_pans_and_guides_from_rulers() { let mut d = Driver::demo(); diff --git a/docs/monitors.md b/docs/monitors.md index 3610c9ea..f8cdd36d 100644 --- a/docs/monitors.md +++ b/docs/monitors.md @@ -15,7 +15,7 @@ focused monitor (Program by default). Code: `crates/ui-egui/src/panels/monitor_v | `view.display.multicam` | Display Mode ▸ Multi-Camera | the Program's multi-camera view (`multicam.toggleView`) | | `view.display.audioWaveform`, `view.display.videoAndWaveform` | Display Mode ▸ Audio Waveform / Video and Audio Waveform Split | Source Monitor: the clip's waveform (click to move the source playhead); audio-only clips always show it | | `view.display.comparison` | Display Mode ▸ Comparison View | Program: the reference frame (left) beside the current frame; the reference starts at the playhead and is stepped or reset with the buttons under it, or `view.compare.setReference {time\|seconds}` | -| `view.magnification.` | View ▸ Magnification and the zoom dropdown | 100% = one frame pixel per screen pixel; scroll or drag with the Hand tool to pan | +| `view.magnification.` | View ▸ Magnification and the zoom dropdown | 100% = one frame pixel per screen pixel. The mouse wheel (or a pinch) over a monitor zooms about the pointer, from Fit too, within 10%–1600%; drag with the middle mouse button, or with the Hand tool, to pan; Fit resets both. Each monitor keeps its own zoom and pan | | `view.showRulers`, `view.showGuides`, `view.lockGuides`, `view.clearGuides` | View | toggles take `{"enabled": bool}` | | `view.addGuide` | View ▸ Add Guide… | dialog, or `{"orientation": "vertical\|horizontal", "position": px}` | | `view.snapInProgramMonitor` | View ▸ Snap in Program Monitor | graphic moves snap to the frame edges / centre and the guides (6 pt) | From b9f60842bbd49a9a247ae389cb170d8e32edf8ab Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 00:56:35 +0200 Subject: [PATCH 141/144] #814: AAC Audio export (audio-only .m4a, or ADTS .aac) Problem: Premiere's "AAC Audio" format has no FilmCraft equivalent. AAC was only written inside H.264 / H.265 / AV1 movies; the only audio-only exports were Waveform Audio and AIFF. Fix: a new `aac` export format ("AAC Audio") that encodes the sequence mix with the existing AAC-LC encoder into an MPEG-4 audio file (.m4a, encoder delay in an edit list), or a raw ADTS stream when the path ends in .aac. Bitrate, sample rate, channels and loudness normalisation follow the audio settings. Adds an "AAC Audio 48 kHz 320 kbps" Audio Only preset, CLI extension guessing (.m4a / .aac), es/fr/ja/zh-cn strings, and updates G13 (also noting ProRes 4444 / XQ is already in the export UI). Co-Authored-By: Claude Opus 5.5 --- apps/filmcraft-cli/src/main.rs | 3 +- crates/engine/src/commands.rs | 2 +- crates/engine/src/export_tests.rs | 29 ++++ crates/engine/src/export_tools.rs | 3 +- crates/export/src/aac_out.rs | 201 ++++++++++++++++++++++++++++ crates/export/src/lib.rs | 17 ++- crates/export/src/presets.rs | 10 ++ crates/export/src/settings.rs | 10 +- crates/export/src/settings_tests.rs | 99 +++++++++++++- crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/zh-cn.tsv | 3 + docs/architecture.md | 1 + docs/file-format-parity.md | 2 +- docs/gaps.md | 6 +- 16 files changed, 382 insertions(+), 13 deletions(-) create mode 100644 crates/export/src/aac_out.rs diff --git a/apps/filmcraft-cli/src/main.rs b/apps/filmcraft-cli/src/main.rs index 9ead306d..10592444 100644 --- a/apps/filmcraft-cli/src/main.rs +++ b/apps/filmcraft-cli/src/main.rs @@ -79,7 +79,7 @@ SUBCOMMANDS [--scale f] [--quality 0-100] [--no-audio] [--queue] export the active sequence and wait for it to finish: with an export preset (`export --list-presets`; built-in or the user's), or - a format (h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff, guessed + a format (h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff|aac, guessed from the extension); --range entire|inOut|workArea, or a custom range in seconds; --settings is ExportSettings JSON merged over the preset; --scale renders at a fraction of the frame size (0.5 = @@ -209,6 +209,7 @@ fn format_for(path: &str) -> Option<&'static str> { "tif" | "tiff" => "tiff", "bmp" => "bmp", "aif" | "aiff" => "aiff", + "m4a" | "aac" => "aac", _ => return None, }) } diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index e3b162fd..c31894f3 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1285,7 +1285,7 @@ fn build() -> Vec { "Media…", ["File", "Export"], None, - r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|jpg|tga|dpx|gif|wav|aiff"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass|crf"?,"crf":0..51=23,"hardwareEncoding":"off|auto"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"alpha":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq|4444|4444xq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#, + r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|jpg|tga|dpx|gif|wav|aiff|aac"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass|crf"?,"crf":0..51=23,"hardwareEncoding":"off|auto"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"alpha":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq|4444|4444xq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#, has_seq, crate::export_tools::export_media ), diff --git a/crates/engine/src/export_tests.rs b/crates/engine/src/export_tests.rs index 6a8c5d71..82245e97 100644 --- a/crates/engine/src/export_tests.rs +++ b/crates/engine/src/export_tests.rs @@ -177,6 +177,16 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() { assert!((num(&j["format"]["duration"]) - 0.25).abs() < 0.01, "{name}: {}", j["format"]["duration"]); continue; } + "aac" => { + // AAC Audio: an MPEG-4 audio file, sound only + assert!(out.ends_with(".m4a"), "{name}: {out}"); + assert!(fmt_name.contains("m4a"), "{name}: {fmt_name}"); + 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"]); + continue; + } "gif" => { let v = stream(&j, "video").unwrap(); assert_eq!(v["codec_name"], "gif"); @@ -281,6 +291,25 @@ 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(); + let dir = Scratch::new("aac-audio"); + let range = json!({"range": "custom", "startSeconds": 1.0, "endSeconds": 1.25, "wait": true, "format": "aac"}); + let run = |s: &mut Session, path: String| { + let mut p = range.clone(); + p["path"] = json!(path); + s.execute("file.exportMedia", p).unwrap()["path"].as_str().unwrap().to_string() + }; + let adts = run(&mut s, dir.path("Mix.aac")); + assert!(adts.ends_with("Mix.aac"), "{adts}"); + let b = std::fs::read(&adts).unwrap(); + assert_eq!((b[0], b[1]), (0xFF, 0xF1), "an ADTS header"); + let m4a = run(&mut s, dir.path("Mix")); + assert!(m4a.ends_with("Mix.m4a"), "{m4a}"); + assert_eq!(&std::fs::read(&m4a).unwrap()[4..8], b"ftyp"); +} + #[test] fn h264_presets_hit_their_bitrates() { let mut s = demo(); diff --git a/crates/engine/src/export_tools.rs b/crates/engine/src/export_tools.rs index 9e37a780..b714e284 100644 --- a/crates/engine/src/export_tools.rs +++ b/crates/engine/src/export_tools.rs @@ -363,7 +363,8 @@ fn with_extension(path: &str, settings: &ExportSettings) -> String { Some(e) if e.eq_ignore_ascii_case(ext) || (settings.format == Format::TiffSequence && e.eq_ignore_ascii_case("tiff")) - || (settings.format == Format::JpegSequence && e.eq_ignore_ascii_case("jpeg")) => + || (settings.format == Format::JpegSequence && e.eq_ignore_ascii_case("jpeg")) + || (settings.format == Format::Aac && e.eq_ignore_ascii_case("aac")) => { path.to_string() } diff --git a/crates/export/src/aac_out.rs b/crates/export/src/aac_out.rs new file mode 100644 index 00000000..0e18b1fd --- /dev/null +++ b/crates/export/src/aac_out.rs @@ -0,0 +1,201 @@ +//! AAC Audio: the sequence mix alone, AAC-LC from the same encoder as the MPEG-4 movies, in an +//! MPEG-4 audio file (`.m4a`, the encoder delay signalled by an edit list) or, when the path ends +//! in `.aac`, a raw ADTS stream (ISO/IEC 14496-3 §1.A.2; one header per access unit, no CRC). ADTS +//! cannot signal the encoder delay, so a player starts it with the priming samples. + +use std::io::Write; +use std::sync::Arc; +use std::sync::atomic::Ordering; + +use filmcraft_isobmff::{Brand, CodecConfig, Mp4Writer, TrackConfig, WriteSample, WriterOptions}; +use filmcraft_project::{ItemId, Project}; +use filmcraft_render::SourceProvider; +use filmcraft_time::TimeRange; + +use crate::audio_out::AudioOut; +use crate::{AudioEncoder, ExportError, ExportSettings, Format, Out, Progress, Result, audio_factories}; + +/// Whether `path` asks for a raw ADTS stream rather than an MPEG-4 file. +pub(crate) fn is_adts_path(path: &str) -> bool { + std::path::Path::new(path).extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("aac")) +} + +/// Where the encoded access units go. +enum Sink { + Mp4 { mux: Mp4Writer, track: usize, frame: u32 }, + Adts { out: Out, header: AdtsHeader }, +} + +impl Sink { + fn write(&mut self, units: Vec>) -> Result<()> { + let io = |e: std::io::Error| ExportError::Io(e.to_string()); + match self { + Sink::Mp4 { mux, track, frame } => { + for au in units { + mux.write_sample(*track, WriteSample { data: &au, duration: *frame, composition_offset: 0, is_sync: true }) + .map_err(|e| ExportError::Io(e.to_string()))?; + } + } + Sink::Adts { out, header } => { + for au in units { + out.write_all(&header.bytes(au.len())?).map_err(io)?; + out.write_all(&au).map_err(io)?; + } + } + } + Ok(()) + } + + fn finish(self, settings: &ExportSettings) -> Result { + match self { + Sink::Mp4 { mux, .. } => mux.finish().map_err(|e| ExportError::Io(e.to_string()))?.finish(settings), + Sink::Adts { out, .. } => out.finish(settings), + } + } +} + +/// The fixed fields of an ADTS header, from the AudioSpecificConfig. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct AdtsHeader { + /// `profile_ObjectType`: the audio object type minus one (1 = AAC-LC). + profile: u8, + sf_index: u8, + channel_config: u8, +} + +impl AdtsHeader { + /// Read the object type, sampling frequency index and channel configuration from an + /// AudioSpecificConfig; ADTS can carry only object types 1–4 with an indexed rate and a + /// channel configuration of 1–7. + pub(crate) fn from_asc(asc: &[u8]) -> Result { + let (Some(&a), Some(&b)) = (asc.first(), asc.get(1)) else { + return Err(ExportError::Encode("AAC: the encoder gave no AudioSpecificConfig".into())); + }; + let object_type = a >> 3; + let sf_index = ((a & 0x07) << 1) | (b >> 7); + let channel_config = (b >> 3) & 0x0F; + if !(1..=4).contains(&object_type) || sf_index > 12 || !(1..=7).contains(&channel_config) { + return Err(ExportError::Unsupported(format!( + "an ADTS .aac stream cannot carry this AAC configuration (object type {object_type}, rate index {sf_index}, channels {channel_config}): export .m4a instead" + ))); + } + Ok(AdtsHeader { profile: object_type - 1, sf_index, channel_config }) + } + + /// The 7-byte header (`protection_absent`) of an access unit of `payload` bytes. + pub(crate) fn bytes(&self, payload: usize) -> Result<[u8; 7]> { + // aac_frame_length is 13 bits and counts the header + let too_big = || ExportError::Encode(format!("AAC: a {payload}-byte frame does not fit ADTS")); + let len = u32::try_from(payload).ok().and_then(|n| n.checked_add(7)).filter(|n| *n < 1 << 13).ok_or_else(too_big)?; + Ok([ + 0xFF, + 0xF1, // syncword, MPEG-4, layer 0, protection absent + (self.profile << 6) | (self.sf_index << 2) | (self.channel_config >> 2), + ((self.channel_config & 0x03) << 6) | ((len >> 11) & 0x03) as u8, + ((len >> 3) & 0xFF) as u8, + (((len & 0x07) << 5) as u8) | 0x1F, // buffer fullness 0x7FF: variable rate + 0xFC, // … and one raw data block + ]) + } +} + +/// Encode the mix of `range` to `settings.path`; returns (bytes written, samples per channel). +pub(crate) fn export( + project: &Arc, + seq: ItemId, + settings: &ExportSettings, + range: TimeRange, + sources: &dyn SourceProvider, + progress: &Progress, +) -> Result<(u64, u64)> { + let cancelled = || progress.cancel.load(Ordering::Relaxed); + let mut a = AudioOut::new(project.clone(), seq, settings, range)?; + let mut enc: Box = audio_factories() + .read() + .unwrap_or_else(|e| e.into_inner()) + .iter() + .find_map(|f| f(Format::Aac, a.sr, a.channels as u32, settings)) + .ok_or_else(|| ExportError::Unsupported("AAC encoder not available".into()))??; + let entry = enc.sample_entry(); + let frames = a.remaining(); + // one second per chunk: memory stays flat however long the range is + let chunk = i64::from(a.sr.max(1)); + if !settings.part_of_batch { + progress.total.store(frames.div_ceil(chunk as u64).max(1), Ordering::Relaxed); + progress.set_status(format!("Exporting audio ({})", settings.format.label())); + } + a.measure(settings, sources, &cancelled)?; + *progress.loudness.lock().unwrap_or_else(|e| e.into_inner()) = a.loudness; + let mut sink = if is_adts_path(&settings.path) { + let CodecConfig::Aac(cfg) = &entry.codec else { + return Err(ExportError::Encode("AAC: the encoder's sample entry is not AAC".into())); + }; + Sink::Adts { header: AdtsHeader::from_asc(&cfg.asc)?, out: Out::create(settings)? } + } else { + let mut opts = WriterOptions::new(Brand::Mp4); + opts.metadata = settings.metadata.udta(); + let mut mux = Mp4Writer::new(Out::create(settings)?, opts).map_err(|e| ExportError::Io(e.to_string()))?; + let mut track = TrackConfig::new(entry, a.sr); + track.media_start = Some(i64::from(enc.priming())); + let track = mux.add_track(track).map_err(|e| ExportError::Io(e.to_string()))?; + Sink::Mp4 { mux, track, frame: enc.frame_size() } + }; + while let Some(planar) = a.pull(a.out.saturating_add(chunk), sources) { + if cancelled() { + return Err(ExportError::Cancelled); + } + sink.write(enc.encode(&planar)?)?; + if !settings.part_of_batch { + progress.done.fetch_add(1, Ordering::Relaxed); + } + } + sink.write(enc.flush()?)?; + let n = sink.finish(settings)?; + if settings.part_of_batch { + progress.done.store(1, Ordering::Relaxed); + } + Ok((n, frames)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn adts_header_fields_follow_the_asc() { + // AAC-LC (2), 48 kHz (index 3), stereo: 00010 0011 0010 000 + let h = AdtsHeader::from_asc(&[0x11, 0x90]).unwrap(); + assert_eq!(h, AdtsHeader { profile: 1, sf_index: 3, channel_config: 2 }); + let b = h.bytes(371).unwrap(); + assert_eq!(&b[..2], &[0xFF, 0xF1]); + assert_eq!(b[2] >> 6, 1); + assert_eq!((b[2] >> 2) & 0x0F, 3); + assert_eq!(((b[2] & 1) << 2) | (b[3] >> 6), 2); + let len = (u32::from(b[3] & 0x03) << 11) | (u32::from(b[4]) << 3) | u32::from(b[5] >> 5); + assert_eq!(len, 378); + assert_eq!(((u32::from(b[5] & 0x1F)) << 6) | u32::from(b[6] >> 2), 0x7FF); + assert_eq!(b[6] & 0x03, 0); + } + + #[test] + fn adts_refuses_what_it_cannot_signal() { + assert!(AdtsHeader::from_asc(&[]).is_err()); + assert!(AdtsHeader::from_asc(&[0x11]).is_err()); + // explicit sampling frequency (index 15) + assert!(AdtsHeader::from_asc(&[0x17, 0x90, 0, 0, 0]).is_err()); + // object type 5 (SBR) has no ADTS profile + assert!(AdtsHeader::from_asc(&[0x29, 0x90]).is_err()); + let h = AdtsHeader::from_asc(&[0x11, 0x90]).unwrap(); + assert!(h.bytes(8184).is_ok()); + assert!(h.bytes(8185).is_err()); + assert!(h.bytes(usize::MAX).is_err()); + } + + #[test] + fn only_a_dot_aac_path_is_adts() { + assert!(is_adts_path("/x/mix.aac")); + assert!(is_adts_path("mix.AAC")); + assert!(!is_adts_path("mix.m4a")); + assert!(!is_adts_path("aac")); + } +} diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 279384f2..9a7d15dc 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -15,6 +15,7 @@ use std::io::Write; +mod aac_out; mod audio_out; mod job; mod mxf_out; @@ -112,6 +113,10 @@ pub enum Format { Wav, #[serde(rename = "aiff", alias = "Aiff")] Aiff, + /// AAC Audio: the mix alone, AAC-LC in an MPEG-4 audio file (`.m4a`), or a raw ADTS stream when + /// the path ends in `.aac`. + #[serde(rename = "aac", alias = "Aac")] + Aac, /// MXF OP1a (SMPTE ST 378): frame-wrapped DNxHR, ProRes or H.264 ([`MxfVideoCodec`]) + PCM. #[serde(rename = "mxf-op1a", alias = "MxfOp1a")] MxfOp1a, @@ -143,6 +148,7 @@ impl Format { "gif" | "animatedgif" => Format::Gif, "wav" | "waveform" | "waveformaudio" => Format::Wav, "aif" | "aiff" | "aifc" => Format::Aiff, + "aac" | "aacaudio" | "m4a" | "adts" => Format::Aac, _ => return None, }) } @@ -165,6 +171,7 @@ impl Format { Format::Gif => "gif", Format::Wav => "wav", Format::Aiff => "aiff", + Format::Aac => "aac", Format::MxfOp1a => "mxf-op1a", Format::MxfOpAtom => "mxf-opatom", } @@ -182,6 +189,7 @@ impl Format { Format::Gif => "gif", Format::Wav => "wav", Format::Aiff => "aif", + Format::Aac => "m4a", Format::MxfOp1a | Format::MxfOpAtom => "mxf", } } @@ -203,6 +211,7 @@ impl Format { Format::Gif => "Animated GIF", Format::Wav => "Waveform Audio", Format::Aiff => "AIFF", + Format::Aac => "AAC Audio", Format::MxfOp1a => "MXF OP1a", Format::MxfOpAtom => "MXF OP-Atom", } @@ -220,6 +229,10 @@ impl Format { pub fn has_builtin_encoder(self) -> bool { !matches!(self, Format::Hevc | Format::Av1) } + /// Sound without picture: Waveform Audio, AIFF and AAC Audio. + pub fn is_audio_only(self) -> bool { + matches!(self, Format::Wav | Format::Aiff | Format::Aac) + } /// H.264 or H.265: MPEG-4 (or QuickTime) with AAC audio, set up with the same bitrate controls. pub fn is_h26x(self) -> bool { matches!(self, Format::H264 | Format::Hevc) @@ -232,7 +245,7 @@ impl Format { pub fn is_mp4_with(self, mux: Multiplexer) -> bool { self == Format::Av1 || (self.is_h26x() && mux == Multiplexer::Mp4) } - pub const ALL: [Format; 18] = [ + pub const ALL: [Format; 19] = [ Format::H264, Format::Hevc, Format::Av1, @@ -249,6 +262,7 @@ impl Format { Format::Gif, Format::Wav, Format::Aiff, + Format::Aac, Format::MxfOp1a, Format::MxfOpAtom, ]; @@ -1719,6 +1733,7 @@ pub fn export(project: &Arc, seq: ItemId, settings: &ExportSettings, so let cancelled = || progress.cancel.load(Ordering::Relaxed); let batch = rayon::current_num_threads().clamp(2, 16) as i64; let (bytes, nframes) = match settings.format { + Format::Aac => aac_out::export(project, seq, settings, range, sources, progress)?, Format::Wav | Format::Aiff => { let mut a = audio_out::AudioOut::new(project.clone(), seq, settings, range)?; let (ch, sr, bits) = (a.channels as u16, a.sr, settings.audio.bits); diff --git a/crates/export/src/presets.rs b/crates/export/src/presets.rs index 2a021d49..c6f04979 100644 --- a/crates/export/src/presets.rs +++ b/crates/export/src/presets.rs @@ -169,6 +169,16 @@ pub fn builtin_presets() -> Vec { p("Waveform Audio 48 kHz 16-bit", "Audio Only", "WAV, stereo 48 kHz 16-bit PCM", audio_only(Format::Wav, 16)), p("Waveform Audio 48 kHz 24-bit", "Audio Only", "WAV, stereo 48 kHz 24-bit PCM", audio_only(Format::Wav, 24)), p("AIFF 48 kHz 16-bit", "Audio Only", "AIFF, stereo 48 kHz 16-bit PCM", audio_only(Format::Aiff, 16)), + p( + "AAC Audio 48 kHz 320 kbps", + "Audio Only", + "MPEG-4 audio (.m4a), stereo 48 kHz AAC-LC at 320 kbps", + ExportSettings { + format: Format::Aac, + audio: AudioSettings { codec: AudioCodec::Aac, sample_rate: Some(48_000), channels: 2, bitrate_kbps: 320, bits: 16 }, + ..Default::default() + }, + ), ] } diff --git a/crates/export/src/settings.rs b/crates/export/src/settings.rs index 61ee9dbd..4bfa28ec 100644 --- a/crates/export/src/settings.rs +++ b/crates/export/src/settings.rs @@ -160,7 +160,7 @@ impl MxfVideoCodec { } } -/// Audio codec. `Auto` = AAC in MP4, PCM in QuickTime / WAV / AIFF. +/// Audio codec. `Auto` = AAC in MP4 and AAC Audio, PCM in QuickTime / WAV / AIFF. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum AudioCodec { @@ -432,6 +432,7 @@ impl ExportSettings { pub fn audio_codec(&self) -> AudioCodec { match (self.format, self.audio.codec) { (Format::Wav | Format::Aiff | Format::MxfOp1a | Format::MxfOpAtom, _) => AudioCodec::Pcm, + (Format::Aac, _) => AudioCodec::Aac, (f, _) if f.is_mp4_with(self.multiplexer) => AudioCodec::Aac, (_, AudioCodec::Auto) if self.format.is_mp4_video() => AudioCodec::Aac, (_, AudioCodec::Auto) => AudioCodec::Pcm, @@ -446,7 +447,7 @@ impl ExportSettings { /// Whether the format carries video. pub fn has_video(&self) -> bool { - !matches!(self.format, Format::Wav | Format::Aiff) + !self.format.is_audio_only() } /// File extension of the output. @@ -461,6 +462,7 @@ impl ExportSettings { let fps = r.rate.num as f64 / r.rate.den as f64; let px = r.width as f64 * r.height as f64; let video_bps = match self.video_format() { + Format::Aac => 0.0, Format::H264 | Format::Hevc | Format::Av1 => r.target_kbps as f64 * 1000.0, Format::ProRes => crate::prores_profile(&self.prores_profile).nominal_mbps_1080p30() * 1e6 * px / (1920.0 * 1080.0) * fps / 29.97, Format::DnxHr => { @@ -491,7 +493,7 @@ impl ExportSettings { Format::Gif => px * 0.6 * 8.0 * fps, Format::Wav | Format::Aiff | Format::MxfOp1a | Format::MxfOpAtom => 0.0, }; - let audio_bps = if self.has_audio() || matches!(self.format, Format::Wav | Format::Aiff) { + let audio_bps = if self.has_audio() || self.format.is_audio_only() { match self.audio_codec() { AudioCodec::Aac => self.audio.bitrate_kbps as f64 * 1000.0, _ => r.sample_rate as f64 * r.channels as f64 * if self.audio.bits >= 24 { 24.0 } else { 16.0 }, @@ -575,7 +577,7 @@ impl ExportSettings { } v }; - let audio = if self.has_audio() || matches!(self.format, Format::Wav | Format::Aiff) { + let audio = if self.has_audio() || self.format.is_audio_only() { let ch = match r.channels { 1 => "Mono", 6 => "5.1", diff --git a/crates/export/src/settings_tests.rs b/crates/export/src/settings_tests.rs index 8cfb3170..1b72f10b 100644 --- a/crates/export/src/settings_tests.rs +++ b/crates/export/src/settings_tests.rs @@ -218,6 +218,103 @@ fn wav_and_aiff_audio_only() { } } +/// Root-mean-square level of decoded planar audio. +fn rms(planes: &[Vec]) -> f32 { + let n = planes.iter().map(Vec::len).sum::().max(1); + (planes.iter().flatten().map(|s| s * s).sum::() / n as f32).sqrt() +} + +#[test] +fn aac_audio_writes_m4a_and_adts() { + let (p, seq, m) = matte([0.0, 0.0, 0.0, 1.0], 64, 36, Some(-6.0)); + let dir = Scratch::new("aac-audio"); + let range = Some(TimeRange::new(Tick::ZERO, Tick(TICKS_PER_SECOND))); + // .m4a: one AAC track, no video, the encoder delay in an edit list + let m4a = dir.path("a.m4a"); + let s = ExportSettings { format: Format::Aac, path: m4a.clone(), range, ..Default::default() }; + assert!(!s.has_video()); + assert_eq!(s.audio_codec(), AudioCodec::Aac); + assert_eq!(s.extension(), "m4a"); + let r = export(&p, seq, &s, &m, &Progress::default()).unwrap(); + assert_eq!(r.frames, 48_000, "one second of samples"); + let bytes = std::fs::read(&m4a).unwrap(); + let file = filmcraft_isobmff::open(bytes.clone()).unwrap(); + assert_eq!(file.tracks.len(), 1, "audio only"); + let t = &file.tracks[0]; + let filmcraft_isobmff::CodecConfig::Aac(cfg) = &t.entries[0].codec else { panic!("not AAC: {:?}", t.entries[0].codec) }; + assert_eq!((cfg.object_type, cfg.sample_rate, cfg.channel_config), (2, 48_000, 2)); + assert!(t.samples.len() >= 48_000 / 1024, "{} access units", t.samples.len()); + let mut dec = filmcraft_aac::Decoder::new(&cfg.asc).unwrap(); + let mut pcm: Vec> = Vec::new(); + for i in 0..t.samples.len() { + let out = dec.decode(&file.read_sample(&bytes, 0, i).unwrap()).unwrap(); + pcm.resize(out.len(), Vec::new()); + pcm.iter_mut().zip(out).for_each(|(a, b)| a.extend(b)); + } + // the -6 dBFS tone (RMS about 0.35) survives the round trip + assert!((0.2..0.5).contains(&rms(&pcm)), "rms {}", rms(&pcm)); + if let Some(j) = ffprobe_json(&["-show_streams", "-show_format"], &m4a) { + assert_eq!(j["streams"].as_array().unwrap().len(), 1); + let st = &j["streams"][0]; + assert_eq!(st["codec_name"], "aac"); + assert_eq!(st["sample_rate"], "48000"); + assert_eq!(st["channels"], 2); + let d: f64 = j["format"]["duration"].as_str().unwrap().parse().unwrap(); + assert!((d - 1.0).abs() < 0.03, "duration {d}"); + } + + // .aac: raw ADTS, mono 44.1 kHz at 128 kbps + let adts = dir.path("a.aac"); + let mut s = ExportSettings { format: Format::Aac, path: adts.clone(), range, ..Default::default() }; + s.audio = AudioSettings { sample_rate: Some(44_100), channels: 1, bitrate_kbps: 128, ..Default::default() }; + export(&p, seq, &s, &m, &Progress::default()).unwrap(); + let b = std::fs::read(&adts).unwrap(); + // AAC-LC 44.1 kHz (index 4) mono: 00010 0100 0001 000 + let mut dec = filmcraft_aac::Decoder::new(&[0x12, 0x08]).unwrap(); + let (mut pos, mut units, mut pcm) = (0usize, 0usize, vec![Vec::new()]); + while pos < b.len() { + let h = &b[pos..pos + 7]; + assert_eq!((h[0], h[1] & 0xF6), (0xFF, 0xF0), "ADTS sync at {pos}"); + assert_eq!(h[1] & 0x01, 1, "no CRC"); + assert_eq!(((h[2] >> 2) & 0x0F, ((h[2] & 1) << 2) | (h[3] >> 6)), (4, 1), "44.1 kHz mono"); + let len = ((h[3] as usize & 0x03) << 11) | ((h[4] as usize) << 3) | (h[5] as usize >> 5); + assert!(len > 7 && pos + len <= b.len(), "frame length {len} at {pos}"); + pcm[0].extend(dec.decode(&b[pos + 7..pos + len]).unwrap().swap_remove(0)); + pos += len; + units += 1; + } + assert!(units >= 44_100 / 1024, "{units} frames"); + assert!((0.2..0.5).contains(&rms(&pcm)), "rms {}", rms(&pcm)); + if let Some(j) = ffprobe_json(&["-show_streams", "-show_format"], &adts) { + assert_eq!(j["format"]["format_name"], "aac"); + let st = &j["streams"][0]; + assert_eq!(st["codec_name"], "aac"); + assert_eq!(st["sample_rate"], "44100"); + assert_eq!(st["channels"], 1); + } + if let Some(ffmpeg) = filmcraft_testkit::ffmpeg_or_skip("AAC Audio decode") { + for f in [&m4a, &adts] { + let out = std::process::Command::new(&ffmpeg).args(["-v", "error", "-xerror", "-i", f, "-f", "null", "-"]).output().unwrap(); + assert!(out.status.success() && out.stderr.is_empty(), "{f}: {}", String::from_utf8_lossy(&out.stderr)); + } + } +} + +#[test] +fn aac_audio_settings_and_names() { + assert_eq!(Format::from_name("m4a"), Some(Format::Aac)); + assert_eq!(Format::from_name("AAC Audio"), Some(Format::Aac)); + assert_eq!(Format::from_name(Format::Aac.id()), Some(Format::Aac)); + assert!(Format::ALL.contains(&Format::Aac) && Format::Aac.is_audio_only() && !Format::H264.is_audio_only()); + let s = ExportSettings { format: Format::Aac, include_audio: false, ..Default::default() }; + let sum = s.summary(1920, 1080, FrameRate::FPS_24, 48_000, Tick(10 * TICKS_PER_SECOND)); + assert_eq!(sum.video, "No video"); + assert_eq!(sum.audio, "AAC, 320 kbps, 48000 Hz, Stereo"); + assert_eq!(sum.estimated_bytes, 400_000, "320 kbps for 10 s, no picture"); + let preset = presets::find_builtin("AAC Audio 48 kHz 320 kbps").unwrap(); + assert_eq!(preset.settings.format, Format::Aac); +} + #[test] fn wav_export_honours_explicit_sample_rates() { let (project, sequence, sources) = matte([0.0, 0.0, 0.0, 1.0], 64, 36, Some(-6.0)); @@ -658,7 +755,7 @@ fn h265_is_a_format_that_needs_a_registered_encoder() { } assert_eq!(Format::from_name(Format::Hevc.id()), Some(Format::Hevc)); assert_eq!((Format::Hevc.extension(), Format::Hevc.label()), ("mp4", "H.265 (HEVC)")); - assert!(Format::ALL.contains(&Format::Hevc) && Format::ALL.len() == 18); + assert!(Format::ALL.contains(&Format::Hevc) && Format::ALL.len() == 19); let s: ExportSettings = serde_json::from_value(serde_json::json!({"format": "hevc"})).unwrap(); assert_eq!(s.format, Format::Hevc); assert_eq!(serde_json::to_value(&s).unwrap()["format"], "hevc"); diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 51d1cd03..4f76c350 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2974,6 +2974,9 @@ Step Forward 1 Frame Avanzar 1 fotograma Match Playhead Igualar cabezal Gang to Program Vincular al programa + AAC Audio AAC Audio + AAC Audio 48 kHz 320 kbps AAC Audio 48 kHz 320 kbps + MPEG-4 audio (.m4a), stereo 48 kHz AAC-LC at 320 kbps Audio MPEG-4 (.m4a), estéreo, AAC-LC de 48 kHz a 320 kbps AIFF AIFF AIFF 48 kHz 16-bit AIFF 48 kHz 16 bits AIFF, stereo 48 kHz 16-bit PCM AIFF, estéreo, PCM de 48 kHz y 16 bits diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index f9f8c660..5f17a4f4 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2976,6 +2976,9 @@ Step Forward 1 Frame Avancer d'une image Match Playhead Faire correspondre la tête de lecture Gang to Program Coupler au moniteur programme + AAC Audio AAC Audio + AAC Audio 48 kHz 320 kbps AAC Audio 48 kHz 320 kbps + MPEG-4 audio (.m4a), stereo 48 kHz AAC-LC at 320 kbps Audio MPEG-4 (.m4a), AAC-LC stéréo 48 kHz à 320 kbps AIFF AIFF AIFF 48 kHz 16-bit AIFF 48 kHz 16 bits AIFF, stereo 48 kHz 16-bit PCM AIFF, PCM stéréo 48 kHz 16 bits diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 79c7d025..630cbd4d 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2963,6 +2963,9 @@ Step Forward 1 Frame 1 フレーム進む Match Playhead 再生ヘッドに合わせる Gang to Program プログラムと連動 + AAC Audio AAC Audio + AAC Audio 48 kHz 320 kbps AAC Audio 48 kHz 320 kbps + MPEG-4 audio (.m4a), stereo 48 kHz AAC-LC at 320 kbps MPEG-4 オーディオ (.m4a)、ステレオ 48 kHz AAC-LC 320 kbps AIFF AIFF AIFF 48 kHz 16-bit AIFF 48 kHz 16-bit AIFF, stereo 48 kHz 16-bit PCM AIFF、ステレオ 48 kHz 16-bit PCM diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index 16472ef0..2d518866 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -2957,6 +2957,9 @@ Step Forward 1 Frame 前进 1 帧 Match Playhead 匹配播放头 Gang to Program 联动到节目 + AAC Audio AAC 音频 + AAC Audio 48 kHz 320 kbps AAC 音频 48 kHz 320 kbps + MPEG-4 audio (.m4a), stereo 48 kHz AAC-LC at 320 kbps MPEG-4 音频 (.m4a),立体声 48 kHz AAC-LC,320 kbps AIFF AIFF AIFF 48 kHz 16-bit AIFF 48 kHz 16 位 AIFF, stereo 48 kHz 16-bit PCM AIFF,立体声 48 kHz 16 位 PCM diff --git a/docs/architecture.md b/docs/architecture.md index ed1486af..cbd1e6ed 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -664,6 +664,7 @@ file.exportMedia {path, preset?, settings?, format?, range?, …} export.qui | `prores` | `filmcraft-prores` (Proxy / LT / 422 / HQ) | MOV | | `dnxhr` | `filmcraft-dnx` (LB / SQ / HQ / HQX) | MOV (`AVdh`) | | `mjpeg` | built in | MOV | +| `aac` | `filmcraft-aac` (AAC-LC, the audio settings' bitrate) | audio only: MPEG-4 audio `.m4a` (`isobmff`, priming in an edit list), or raw ADTS when the path ends in `.aac` | | `mxf-op1a` | DNxHR (default), ProRes or H.264 (Annex B, long GOP), `mxfVideoCodec`; PCM 16/24-bit | MXF OP1a (`filmcraft-mxf` writer: frame-wrapped, index with temporal offsets, start timecode) | | `mxf-opatom` | as `mxf-op1a` | Avid-style MXF OP-Atom: clip-wrapped picture at `path`, one mono PCM file per channel (`_A1.mxf` …, `Report::extra_files`) | | `png`, `tiff`, `bmp`, `jpg` | `image` | numbered stills `000.`, `001.` … | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 58c45cb6..ee18a834 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -68,7 +68,7 @@ only). "Tested" names the evidence. | WAV / BWF (timecode, iXML partial) | read + write | yes | yes | 90% | | AIFF / AIFC | read + write | yes | yes | 95% | | MP3 | read + write | read | **no** | 60% | -| AAC / M4A | read + write | yes | inside MP4 / MOV only, **no AAC-only file** | 70% | +| AAC / M4A | read + write | yes | yes (in MP4 / MOV, and AAC Audio: `.m4a` or ADTS `.aac`, #814) | 95% | | FLAC, Ogg, Opus | partial | yes | no | ahead on read | | WMA | read (Windows) | no | no | 0% | diff --git a/docs/gaps.md b/docs/gaps.md index aec13ce2..c5baf693 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -187,10 +187,10 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** ## G13. Delivery formats - **Missing:** AV1 export (software or hardware), MPEG-2 / DVD / Blu-ray, DCP, JPEG 2000 MXF, - AS-10 / AS-11, XDCAM HD MXF, P2, OpenEXR sequences, MP3 and AAC-only audio, - ProRes 4444 / XQ in the export UI (#342; the encoder has the profiles), interlaced encoding, + AS-10 / AS-11, XDCAM HD MXF, P2, OpenEXR sequences, MP3 audio, interlaced encoding, smart render, HEVC software encoder (HEVC exists only where a hardware encoder does). -- **Have:** PNG, TIFF, BMP, JPEG, Targa and DPX (10-bit) image sequences (#779). +- **Have:** PNG, TIFF, BMP, JPEG, Targa and DPX (10-bit) image sequences (#779); AAC Audio + (audio-only `.m4a`, or ADTS `.aac`; #814); ProRes 4444 / 4444 XQ in the export UI (#342). - **Estimate:** 60–110 h. Doc: [file-format-parity.md](file-format-parity.md). ## G14. Localization: 8 of the 12 key languages missing From fe52bc0b2cc4d1aefb532027c8ce95d7b67f7332 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 00:58:15 +0200 Subject: [PATCH 142/144] #821: import Targa and DPX stills and image sequences Problem: Premiere imports Targa and DPX as stills and numbered sequences. FilmCraft exports both (#779) but could not read either: the extensions were not importable and the image crate has no DPX reader, so even our own Targa / DPX renders could not come back in. Fix: clean-room decoders in filmcraft-media. Targa from the TGA 2.0 spec: image types 1/2/3 and RLE 9/10/11, colour maps, 15/16/24/32-bit, 8/16-bit grayscale, any origin. DPX from SMPTE ST 268: first element, luma / RGB / RGBA / ABGR, 8/16-bit, filled 10/12-bit (methods A and B), either byte order, padded lines, orientations 0-3. Both bound allocations by the data present and reject hostile headers with errors. Stills and image sequences share one decode_raster entry point; "tga" and "dpx" join the importable still extensions. Co-Authored-By: Claude Opus 5.5 --- crates/codecs/tests/media_oracle.rs | 63 +++++- crates/media/src/dpx.rs | 273 +++++++++++++++++++++++++ crates/media/src/lib.rs | 6 +- crates/media/src/sequence.rs | 15 +- crates/media/src/still.rs | 41 +++- crates/media/src/tga.rs | 299 ++++++++++++++++++++++++++++ docs/file-format-parity.md | 6 +- docs/gaps.md | 7 +- 8 files changed, 683 insertions(+), 27 deletions(-) create mode 100644 crates/media/src/dpx.rs create mode 100644 crates/media/src/tga.rs diff --git a/crates/codecs/tests/media_oracle.rs b/crates/codecs/tests/media_oracle.rs index db7745dd..4fc6638e 100644 --- a/crates/codecs/tests/media_oracle.rs +++ b/crates/codecs/tests/media_oracle.rs @@ -1,6 +1,7 @@ -//! Image sequences and Broadcast WAV against ffmpeg: ffmpeg writes numbered stills (PNG, JPEG, -//! TIFF, BMP, WebP when available) and BWF files; the sequence frames must equal ffmpeg's decode of -//! the same image2 sequence (lossless formats exactly, JPEG within a few levels), and the BWF +//! Image sequences, stills and Broadcast WAV against ffmpeg: ffmpeg writes numbered stills (PNG, +//! JPEG, TIFF, BMP, WebP when available, Targa, DPX), single Targa / DPX stills in every layout it +//! has, and BWF files; our frames must equal ffmpeg's decode of the same files (lossless formats +//! 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; @@ -114,6 +115,62 @@ fn webp_sequence_lossless() { check(&ff, "webp", &["-c:v", "libwebp", "-lossless", "1"], 0); } +#[test] +fn targa_and_dpx_sequences_exact() { + let ff = filmcraft_testkit::require_ffmpeg!(); + check(&ff, "tga", &[], 0); + check(&ff, "dpx", &["-pix_fmt", "gbrp10be"], 1); +} + +/// One `ext` still in `pix_fmt` (95 × 63, so scan lines need padding; translucent when the format +/// has alpha) opened like an imported file must match ffmpeg's RGBA decode within `tol` (exactly +/// when `tol` is 0). +fn still_matches(ff: &Path, ext: &str, pix_fmt: &str, extra: &[&str], tol: u8) { + let name = format!("still_{pix_fmt}{}.{ext}", extra.join("")); + let alpha = if pix_fmt.contains('a') && pix_fmt != "pal8" { ",format=rgba,geq=r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':a='X*2+Y'" } else { "" }; + let vf = format!("scale=95:63{alpha}"); + let mut args = vec!["-f", "lavfi", "-i", "testsrc2=size=96x64", "-frames:v", "1", "-vf", &vf, "-pix_fmt", pix_fmt]; + args.extend_from_slice(extra); + let Some(f) = fixture(ff, &name, &args) else { + eprintln!("SKIPPED: ffmpeg cannot write {name}"); + return; + }; + let src = filmcraft_codecs::open_bytes(&name, bytes(&f)).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(src.info().kind, MediaKind::Still, "{name}"); + let v = src.info().video.as_ref().unwrap(); + assert_eq!((v.width, v.height), (95, 63), "{name}"); + let ours = rgba(&src.video_frame(FrameRequest::full(filmcraft_time::Tick(0))).unwrap()); + // deeper than 8 bits: ffmpeg's 16-bit decode, rounded to 8 bits (its own 8-bit conversion dithers) + let want: Vec = if tol == 0 { + ffmpeg_out(ff, &["-i", f.to_str().unwrap(), "-f", "rawvideo", "-pix_fmt", "rgba", "-"]) + } else { + let wide = ffmpeg_out(ff, &["-i", f.to_str().unwrap(), "-f", "rawvideo", "-pix_fmt", "rgba64le", "-"]); + wide.as_chunks::<2>().0.iter().map(|c| ((u32::from(u16::from_le_bytes(*c)) * 255 + 32767) / 65535) as u8).collect() + }; + assert_eq!(ours.len(), want.len(), "{name}"); + let d = ours.iter().zip(&want).map(|(a, b)| a.abs_diff(*b)).max().unwrap(); + assert!(d <= tol, "{name}: differs by {d}"); + assert_eq!(v.has_alpha, want.as_chunks::<4>().0.iter().any(|p| p[3] != 255), "{name}"); + eprintln!("{name}: max diff {d}"); +} + +#[test] +fn targa_stills_every_layout() { + let ff = filmcraft_testkit::require_ffmpeg!(); + for pix_fmt in ["bgr24", "bgra", "rgb555le", "gray", "pal8"] { + still_matches(&ff, "tga", pix_fmt, &[], 0); // run-length encoded (ffmpeg's default) + still_matches(&ff, "tga", pix_fmt, &["-rle", "0"], 0); + } +} + +#[test] +fn dpx_stills_every_layout() { + let ff = filmcraft_testkit::require_ffmpeg!(); + for (pix_fmt, tol) in [("rgb24", 0), ("rgba", 0), ("gray", 0), ("rgb48be", 1), ("rgba64be", 1), ("gray16be", 1), ("gbrp10be", 1), ("gbrp12be", 1)] { + still_matches(&ff, "dpx", pix_fmt, &[], tol); + } +} + #[test] fn missing_frames_hold_the_previous_frame() { let ff = filmcraft_testkit::require_ffmpeg!(); diff --git a/crates/media/src/dpx.rs b/crates/media/src/dpx.rs new file mode 100644 index 00000000..2d5832fa --- /dev/null +++ b/crates/media/src/dpx.rs @@ -0,0 +1,273 @@ +//! DPX (`.dpx`) stills, decoded clean-room from SMPTE ST 268 (Digital Picture Exchange). +//! +//! The first image element is decoded: luma, RGB, RGBA or ABGR, 8, 10, 12 or 16 bits per +//! component, big-endian (`SDPX`) or little-endian (`XPDS`) files. 10-bit components are filled into +//! 32-bit words (packing 1, method A: two padding bits at the bottom; packing 2, method B: at the +//! top), 12-bit components fill 16-bit words the same two ways. Scan lines start on a 32-bit +//! boundary and may carry end-of-line padding. Orientations 0–3 (either scan direction, either line +//! order) are honoured. Code values map linearly onto 8 bits (full range); a logarithmic (printing +//! density) transfer is shown as stored, without a film-log conversion. +//! +//! Not decoded (an "unsupported" error): run-length encoded elements, the 10/12-bit "packed" +//! layout (packing 0), Y'CbCr descriptors, 1-bit and floating-point components, transposed +//! orientations. + +use crate::{MediaError, Result, still::Raster}; + +/// Generic file + image information header: everything we read lies below this. +const HEADER: usize = 1408; +/// First image element of the image information header (72 bytes each). +const ELEMENT: usize = 780; +/// Largest picture we decode (16384 × 16384): bounds the allocation a header can ask for. +const MAX_PIXELS: usize = 1 << 28; +/// "Undefined" value of numeric header fields. +const UNDEFINED: u32 = u32::MAX; + +fn err(name: &str, what: &str) -> MediaError { + MediaError::Decode(format!("{name}: DPX: {what}")) +} + +fn unsupported(name: &str, what: String) -> MediaError { + MediaError::Unsupported(format!("{name}: DPX {what}")) +} + +/// Whether `bytes` starts with the DPX magic number in either byte order. +pub fn sniff(bytes: &[u8]) -> bool { + bytes.starts_with(b"SDPX") || bytes.starts_with(b"XPDS") +} + +/// Header and sample reads in the file's byte order. +#[derive(Clone, Copy)] +struct Order { + big: bool, +} + +impl Order { + fn u16(self, b: &[u8], at: usize) -> u16 { + let v = [b.get(at).copied().unwrap_or(0), b.get(at + 1).copied().unwrap_or(0)]; + if self.big { u16::from_be_bytes(v) } else { u16::from_le_bytes(v) } + } + + fn u32(self, b: &[u8], at: usize) -> u32 { + let v = core::array::from_fn(|i| b.get(at + i).copied().unwrap_or(0)); + if self.big { u32::from_be_bytes(v) } else { u32::from_le_bytes(v) } + } +} + +/// How components are stored in a scan line. +#[derive(Clone, Copy)] +enum Packing { + /// One byte per component. + Bytes, + /// One 16-bit word per component; the value is `word >> shift` (12-bit method A: 4). + Words { shift: u32 }, + /// Three 10-bit components per 32-bit word, the first in the high bits, above `pad` bits. + Ten { pad: u32 }, +} + +impl Packing { + /// Bytes of a scan line holding `comps` components, before end-of-line padding (lines start on + /// a 32-bit boundary). + fn line_bytes(self, comps: usize) -> Option { + let raw = match self { + Packing::Bytes => comps, + Packing::Words { .. } => comps.checked_mul(2)?, + Packing::Ten { .. } => comps.div_ceil(3).checked_mul(4)?, + }; + raw.checked_next_multiple_of(4) + } + + /// The `comps` component values of one scan line. + fn unpack(self, line: &[u8], comps: usize, order: Order, out: &mut Vec) { + out.clear(); + match self { + Packing::Bytes => out.extend(line.iter().take(comps).map(|&b| u16::from(b))), + Packing::Words { shift } => out.extend((0..comps).map(|i| order.u16(line, i * 2) >> shift)), + Packing::Ten { pad } => { + for w in 0..comps.div_ceil(3) { + let word = order.u32(line, w * 4); + for s in [20 + pad, 10 + pad, pad] { + if out.len() < comps { + out.push(((word >> s) & 0x3FF) as u16); + } + } + } + } + } + } +} + +/// Decode the first image element of a DPX file to straight-alpha RGBA8, top row first. +pub fn decode(name: &str, bytes: &[u8]) -> Result { + let order = match bytes.get(..4) { + Some(b"SDPX") => Order { big: true }, + Some(b"XPDS") => Order { big: false }, + _ => return Err(err(name, "not a DPX file")), + }; + if bytes.len() < HEADER { + return Err(err(name, "truncated header")); + } + let orientation = order.u16(bytes, 768); + let (w, h) = (order.u32(bytes, 772) as usize, order.u32(bytes, 776) as usize); + let el = ELEMENT; + let descriptor = bytes.get(el + 20).copied().unwrap_or(0); + let bits = bytes.get(el + 23).copied().unwrap_or(0); + let packing = order.u16(bytes, el + 24); + let encoding = order.u16(bytes, el + 26); + let element_offset = order.u32(bytes, el + 28); + let eol_padding = order.u32(bytes, el + 32); + if w == 0 || h == 0 { + return Err(err(name, "empty picture")); + } + let pixels = w.checked_mul(h).filter(|&n| n <= MAX_PIXELS).ok_or_else(|| err(name, "picture too large"))?; + // component order per pixel: indices of R, G, B, A (None: opaque) in the stored components + let (n, map): (usize, [Option; 4]) = match descriptor { + 6 => (1, [Some(0), Some(0), Some(0), None]), + 50 => (3, [Some(0), Some(1), Some(2), None]), + 51 => (4, [Some(0), Some(1), Some(2), Some(3)]), + 52 => (4, [Some(3), Some(2), Some(1), Some(0)]), + d => return Err(unsupported(name, format!("descriptor {d}"))), + }; + if encoding != 0 { + return Err(unsupported(name, "run-length encoding".into())); + } + let layout = match (bits, packing) { + (8, _) => Packing::Bytes, + (16, _) => Packing::Words { shift: 0 }, + (10, 1) => Packing::Ten { pad: 2 }, + (10, 2) => Packing::Ten { pad: 0 }, + (12, 1) => Packing::Words { shift: 4 }, + (12, 2) => Packing::Words { shift: 0 }, + (b, p) => return Err(unsupported(name, format!("{b}-bit components with packing {p}"))), + }; + let (flip_x, flip_y) = match orientation { + 0 => (false, false), + 1 => (true, false), + 2 => (false, true), + 3 => (true, true), + o => return Err(unsupported(name, format!("orientation {o}"))), + }; + let comps = w.checked_mul(n).ok_or_else(|| err(name, "picture too large"))?; + let pad = if eol_padding == UNDEFINED { 0 } else { eol_padding as usize }; + let stride = layout.line_bytes(comps).and_then(|l| l.checked_add(pad)).ok_or_else(|| err(name, "picture too large"))?; + let offset = match element_offset { + 0 | UNDEFINED => order.u32(bytes, 4) as usize, + o => o as usize, + }; + // the last line needs no end-of-line padding + let size = stride.checked_mul(h - 1).and_then(|s| s.checked_add(stride - pad)).ok_or_else(|| err(name, "picture too large"))?; + let data = offset.checked_add(size).and_then(|end| bytes.get(offset..end)).ok_or_else(|| err(name, "truncated image data"))?; + let max = (1u32 << bits) - 1; + let to8 = |v: u16| ((u32::from(v).min(max) * 255 + max / 2) / max) as u8; + let mut rgba = vec![0u8; pixels * 4]; + let mut line = Vec::with_capacity(comps); + for y in 0..h { + let src = data.get(y * stride..).unwrap_or_default(); + layout.unpack(src, comps, order, &mut line); + let dy = if flip_y { h - 1 - y } else { y }; + for x in 0..w { + let dx = if flip_x { w - 1 - x } else { x }; + let o = (dy * w + dx) * 4; + let Some(px) = rgba.get_mut(o..o + 4) else { continue }; + for (c, slot) in map.iter().enumerate() { + let v = match slot { + Some(i) => line.get(x * n + i).map_or(0, |&v| to8(v)), + None => 255, + }; + if let Some(d) = px.get_mut(c) { + *d = v; + } + } + } + } + let has_alpha = map[3].is_some() && rgba.as_chunks::<4>().0.iter().any(|p| p[3] != 255); + Ok(Raster { width: w as u32, height: h as u32, rgba, has_alpha }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A minimal DPX file (header + `data`) in the given byte order. + fn file(big: bool, w: u32, h: u32, descriptor: u8, bits: u8, packing: u16, orientation: u16, data: &[u8]) -> Vec { + let mut f = vec![0u8; 2048]; + let p32 = |f: &mut Vec, at: usize, v: u32| f[at..at + 4].copy_from_slice(&if big { v.to_be_bytes() } else { v.to_le_bytes() }); + let p16 = |f: &mut Vec, at: usize, v: u16| f[at..at + 2].copy_from_slice(&if big { v.to_be_bytes() } else { v.to_le_bytes() }); + f[0..4].copy_from_slice(if big { b"SDPX" } else { b"XPDS" }); + p32(&mut f, 4, 2048); + p16(&mut f, 768, orientation); + p16(&mut f, 770, 1); + p32(&mut f, 772, w); + p32(&mut f, 776, h); + f[ELEMENT + 20] = descriptor; + f[ELEMENT + 23] = bits; + p16(&mut f, ELEMENT + 24, packing); + p32(&mut f, ELEMENT + 28, UNDEFINED); + p32(&mut f, ELEMENT + 32, UNDEFINED); + f.extend_from_slice(data); + f + } + + #[test] + fn ten_bit_method_a_and_b() { + let word = |r: u32, g: u32, b: u32, pad: u32| ((r << (20 + pad)) | (g << (10 + pad)) | (b << pad)).to_be_bytes(); + let mut data = Vec::new(); + data.extend_from_slice(&word(1023, 0, 512, 2)); + data.extend_from_slice(&word(4, 1019, 0, 2)); + let r = decode("a.dpx", &file(true, 2, 1, 50, 10, 1, 0, &data)).unwrap(); + assert_eq!(r.rgba, [255, 0, 128, 255, 1, 254, 0, 255]); + assert!(!r.has_alpha); + let mut data = Vec::new(); + data.extend_from_slice(&word(1023, 0, 512, 0)); + data.extend_from_slice(&word(4, 1019, 0, 0)); + let r = decode("a.dpx", &file(true, 2, 1, 50, 10, 2, 0, &data)).unwrap(); + assert_eq!(r.rgba, [255, 0, 128, 255, 1, 254, 0, 255]); + } + + #[test] + fn rgba_components_roll_across_ten_bit_words() { + // 2 RGBA pixels = 8 components = 3 words; the last word is half empty + let comps = [1023u32, 0, 0, 1023, 0, 1023, 0, 0]; + let mut data = Vec::new(); + for c in comps.chunks(3) { + let get = |i: usize| c.get(i).copied().unwrap_or(0); + data.extend_from_slice(&((get(0) << 22) | (get(1) << 12) | (get(2) << 2)).to_le_bytes()); + } + let r = decode("a.dpx", &file(false, 2, 1, 51, 10, 1, 0, &data)).unwrap(); + assert_eq!(r.rgba, [255, 0, 0, 255, 0, 255, 0, 0]); + assert!(r.has_alpha); + } + + #[test] + fn eight_bit_lines_pad_to_words_and_orientation_flips() { + // 1 × 2 RGB: each 3-byte line padded to 4; bottom-to-top, right-to-left orientation 3 + let r = decode("a.dpx", &file(false, 1, 2, 50, 8, 0, 3, &[1, 2, 3, 0, 4, 5, 6, 0])).unwrap(); + assert_eq!(r.rgba, [4, 5, 6, 255, 1, 2, 3, 255]); + // 2 × 1 luma, 16-bit big-endian; ABGR, 12-bit method A + let r = decode("a.dpx", &file(true, 2, 1, 6, 16, 0, 0, &[0xFF, 0xFF, 0x80, 0x00])).unwrap(); + assert_eq!(r.rgba, [255, 255, 255, 255, 128, 128, 128, 255]); + let r = decode("a.dpx", &file(true, 1, 1, 52, 12, 1, 0, &[0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xF0])).unwrap(); + assert_eq!(r.rgba, [255, 0, 0, 128]); + } + + #[test] + fn hostile_headers_fail_cleanly() { + // huge dimensions with no data, truncated data, unsupported layouts + assert!(decode("a.dpx", &file(true, u32::MAX, u32::MAX, 50, 10, 1, 0, &[])).is_err()); + assert!(decode("a.dpx", &file(true, 4096, 4096, 50, 10, 1, 0, &[0; 64])).is_err()); + assert!(decode("a.dpx", &file(true, 2, 2, 50, 10, 1, 0, &[0; 12])).is_err()); + assert!(decode("a.dpx", &file(true, 1, 1, 100, 8, 0, 0, &[0; 4])).is_err()); + assert!(decode("a.dpx", &file(true, 1, 1, 50, 10, 0, 0, &[0; 4])).is_err()); + assert!(decode("a.dpx", &file(true, 1, 1, 50, 32, 0, 0, &[0; 16])).is_err()); + assert!(decode("a.dpx", &file(true, 1, 1, 50, 8, 0, 5, &[0; 4])).is_err()); + assert!(decode("a.dpx", &file(true, 0, 1, 50, 8, 0, 0, &[0; 4])).is_err()); + assert!(decode("a.dpx", b"SDPX").is_err()); + let mut f = file(true, 1, 1, 50, 8, 0, 0, &[0; 4]); + f[4..8].copy_from_slice(&u32::MAX.to_be_bytes()); + assert!(decode("a.dpx", &f).is_err()); + let mut f = file(true, 1, 1, 50, 8, 0, 0, &[0; 4]); + f[ELEMENT + 32..ELEMENT + 36].copy_from_slice(&(u32::MAX - 1).to_be_bytes()); + assert!(decode("a.dpx", &file(true, 1, 2, 50, 8, 0, 0, &[0; 4])).is_err()); + assert!(decode("a.dpx", &f).is_ok()); + } +} diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index bfc94de7..a73451e7 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -12,11 +12,13 @@ pub mod cache; pub mod cancel; pub mod digits; +pub mod dpx; pub mod generators; pub mod pending; pub mod reader; pub mod sequence; pub mod still; +pub mod tga; pub mod wav; use std::path::Path; @@ -169,7 +171,7 @@ pub trait MediaSource: Send + Sync { pub type SharedSource = Arc; /// File extensions we recognise at import (the media browser filters on these). -pub const STILL_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp"]; +pub const STILL_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "tga", "dpx"]; pub const AUDIO_EXTENSIONS: &[&str] = &["wav", "wave", "bwf", "aif", "aiff", "mp3", "mp2", "m4a", "aac", "flac", "ogg", "opus"]; pub const VIDEO_EXTENSIONS: &[&str] = &[ "mp4", "m4v", "mov", "mkv", "webm", "avi", "mxf", "mts", "m2ts", "m2t", "ts", "mpg", "mpeg", "vob", "mod", "tod", "m2v", "m1v", "mpv", "3gp", "y4m", "apv", @@ -193,7 +195,7 @@ pub fn open_bytes(name: &str, bytes: Arc<[u8]>, extra: &[Opener]) -> Result bool { - b.starts_with(b"\x89PNG") +/// Whether `b` (the contents of the file `name`) is a still image we decode. +pub fn sniff(name: &str, b: &[u8]) -> bool { + crate::dpx::sniff(b) + || crate::tga::sniff(name, b) + || b.starts_with(b"\x89PNG") || b.starts_with(&[0xFF, 0xD8, 0xFF]) || b.starts_with(b"GIF8") || (b.len() > 12 && &b[0..4] == b"RIFF" && &b[8..12] == b"WEBP") @@ -20,6 +23,31 @@ pub fn sniff(b: &[u8]) -> bool { || b.starts_with(b"BM") } +/// A decoded picture: straight-alpha RGBA8, top row first. +pub struct Raster { + pub width: u32, + pub height: u32, + pub rgba: Vec, + /// Whether any pixel is not fully opaque. + pub has_alpha: bool, +} + +/// Decode a still image file (`name` picks Targa, which has no magic number; the content picks +/// every other format). +pub fn decode_raster(name: &str, bytes: &[u8]) -> Result { + if crate::dpx::sniff(bytes) { + return crate::dpx::decode(name, bytes); + } + if crate::tga::sniff(name, bytes) { + return crate::tga::decode(name, bytes); + } + let img = image::load_from_memory(bytes).map_err(|e| MediaError::Decode(format!("{name}: {e}")))?; + let has_alpha = img.color().has_alpha(); + let rgba = img.to_rgba8(); + let (width, height) = rgba.dimensions(); + Ok(Raster { width, height, rgba: rgba.into_raw(), has_alpha }) +} + pub struct StillSource { info: MediaInfo, frame: Arc, @@ -27,11 +55,8 @@ pub struct StillSource { impl StillSource { pub fn decode(name: &str, bytes: &[u8]) -> Result { - let img = image::load_from_memory(bytes).map_err(|e| MediaError::Decode(format!("{name}: {e}")))?; - let has_alpha = img.color().has_alpha(); - let rgba = img.to_rgba8(); - let (w, h) = rgba.dimensions(); - Ok(Self::from_rgba(name, w, h, rgba.into_raw(), has_alpha, bytes.len() as u64)) + let r = decode_raster(name, bytes)?; + Ok(Self::from_rgba(name, r.width, r.height, r.rgba, r.has_alpha, bytes.len() as u64)) } pub fn from_rgba(name: &str, w: u32, h: u32, rgba: Vec, has_alpha: bool, file_size: u64) -> Self { diff --git a/crates/media/src/tga.rs b/crates/media/src/tga.rs new file mode 100644 index 00000000..06e9295d --- /dev/null +++ b/crates/media/src/tga.rs @@ -0,0 +1,299 @@ +//! Targa (`.tga`) stills, decoded clean-room from the Truevision TGA File Format Specification 2.0. +//! +//! Image types 1/2/3 (colour-mapped, true-colour, grayscale) and their run-length encoded forms +//! 9/10/11; 15/16-bit (5-5-5, the top bit as alpha when the descriptor says one attribute bit), +//! 24-bit BGR and 32-bit BGRA true-colour, 8-bit grayscale and 16-bit grayscale + alpha, colour maps +//! of 15/16/24/32-bit entries indexed by 8 or 16 bits; any origin corner. + +use crate::{MediaError, Result, still::Raster}; + +/// Length of the fixed header. +const HEADER: usize = 18; +/// TGA 2.0 files end with this signature (after two 32-bit area offsets). +const FOOTER: &[u8] = b"TRUEVISION-XFILE.\0"; +/// Largest picture we decode (16384 × 16384): bounds the allocation a header can ask for. +const MAX_PIXELS: usize = 1 << 28; + +fn err(name: &str, what: &str) -> MediaError { + MediaError::Decode(format!("{name}: Targa: {what}")) +} + +/// Whether `bytes` looks like a Targa file: a TGA 2.0 footer, or (for the original format, which +/// has no signature) a `.tga` name and a plausible header. +pub fn sniff(name: &str, bytes: &[u8]) -> bool { + let header_ok = || { + let Some(h) = bytes.first_chunk::
() else { return false }; + let (map_type, kind, depth) = (h[1], h[2], h[16]); + let (w, ht) = (u16::from_le_bytes([h[12], h[13]]), u16::from_le_bytes([h[14], h[15]])); + map_type <= 1 && matches!(kind, 1 | 2 | 3 | 9 | 10 | 11) && w > 0 && ht > 0 && matches!(depth, 8 | 15 | 16 | 24 | 32) + }; + let ext = name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()); + let named = matches!(ext.as_deref(), Some("tga" | "targa" | "icb" | "vda" | "vst")); + header_ok() && (named || bytes.ends_with(FOOTER)) +} + +/// Pixel layout of the stored values (after the colour-map lookup, for mapped images). +#[derive(Clone, Copy)] +enum Layout { + /// 15/16-bit `ARRRRRGG GGGBBBBB`, little-endian; `alpha`: the top bit is an alpha bit. + Rgb555 { + alpha: bool, + }, + Bgr24, + Bgra32, + Gray8, + GrayAlpha16, +} + +impl Layout { + fn true_colour(depth: u8, attr_bits: u8) -> Option { + Some(match depth { + 15 => Layout::Rgb555 { alpha: false }, + 16 => Layout::Rgb555 { alpha: attr_bits == 1 }, + 24 => Layout::Bgr24, + 32 => Layout::Bgra32, + _ => return None, + }) + } + + fn bytes(self) -> usize { + match self { + Layout::Rgb555 { .. } | Layout::GrayAlpha16 => 2, + Layout::Bgr24 => 3, + Layout::Bgra32 => 4, + Layout::Gray8 => 1, + } + } + + /// RGBA of one stored value (`p` holds exactly [`bytes`](Self::bytes) bytes). + fn rgba(self, p: &[u8]) -> [u8; 4] { + let b = |i: usize| p.get(i).copied().unwrap_or(0); + match self { + Layout::Rgb555 { alpha } => { + let v = u16::from_le_bytes([b(0), b(1)]); + let five = |s: u16| { + let c = ((v >> s) & 31) as u8; + (c << 3) | (c >> 2) + }; + let a = if !alpha || v & 0x8000 != 0 { 255 } else { 0 }; + [five(10), five(5), five(0), a] + } + Layout::Bgr24 => [b(2), b(1), b(0), 255], + Layout::Bgra32 => [b(2), b(1), b(0), b(3)], + Layout::Gray8 => [b(0), b(0), b(0), 255], + Layout::GrayAlpha16 => [b(0), b(0), b(0), b(1)], + } + } +} + +/// Decode a Targa file to straight-alpha RGBA8, top row first. +pub fn decode(name: &str, bytes: &[u8]) -> Result { + let h = bytes.first_chunk::
().ok_or_else(|| err(name, "truncated header"))?; + let (id_len, map_type, kind) = (usize::from(h[0]), h[1], h[2]); + let map_first = usize::from(u16::from_le_bytes([h[3], h[4]])); + let map_len = usize::from(u16::from_le_bytes([h[5], h[6]])); + let map_depth = h[7]; + let (w, ht) = (usize::from(u16::from_le_bytes([h[12], h[13]])), usize::from(u16::from_le_bytes([h[14], h[15]]))); + let (depth, descriptor) = (h[16], h[17]); + let attr_bits = descriptor & 0x0F; + let (right_to_left, top_to_bottom) = (descriptor & 0x10 != 0, descriptor & 0x20 != 0); + if w == 0 || ht == 0 { + return Err(err(name, "empty picture")); + } + let pixels = w.checked_mul(ht).filter(|&n| n <= MAX_PIXELS).ok_or_else(|| err(name, "picture too large"))?; + let rle = kind >= 9; + // stored value layout and, for colour-mapped images, the palette + let mut palette: Vec<[u8; 4]> = Vec::new(); + let map_entry_layout = if map_type == 1 { + Some(Layout::true_colour(map_depth, if map_depth == 16 { attr_bits.min(1) } else { 0 }).ok_or_else(|| err(name, "colour map entry size"))?) + } else { + None + }; + let mut at = HEADER.checked_add(id_len).ok_or_else(|| err(name, "header"))?; + if let Some(l) = map_entry_layout { + let size = map_len.checked_mul(l.bytes()).ok_or_else(|| err(name, "colour map"))?; + let end = at.checked_add(size).ok_or_else(|| err(name, "colour map"))?; + let map = bytes.get(at..end).ok_or_else(|| err(name, "truncated colour map"))?; + palette = map.chunks_exact(l.bytes()).map(|e| l.rgba(e)).collect(); + at = end; + } + let (layout, mapped) = match kind & 7 { + 1 => { + if map_type != 1 || !matches!(depth, 8 | 16) { + return Err(err(name, "colour-mapped picture without an 8/16-bit index and a colour map")); + } + (if depth == 8 { Layout::Gray8 } else { Layout::GrayAlpha16 }, true) + } + 2 => (Layout::true_colour(depth, attr_bits).ok_or_else(|| err(name, "true-colour pixel depth"))?, false), + 3 => match depth { + 8 => (Layout::Gray8, false), + 16 => (Layout::GrayAlpha16, false), + _ => return Err(err(name, "grayscale pixel depth")), + }, + _ => return Err(MediaError::Unsupported(format!("{name}: Targa image type {kind}"))), + }; + let data = bytes.get(at..).unwrap_or_default(); + let bpp = layout.bytes(); + // the stored values, in file order + let stored: Vec = if rle { + unpack_rle(name, data, pixels, bpp)? + } else { + let n = pixels.checked_mul(bpp).ok_or_else(|| err(name, "picture too large"))?; + data.get(..n).ok_or_else(|| err(name, "truncated pixel data"))?.to_vec() + }; + let mut rgba = vec![0u8; pixels * 4]; + for (i, v) in stored.chunks_exact(bpp).enumerate().take(pixels) { + let (x, y) = (i % w, i / w); + let x = if right_to_left { w - 1 - x } else { x }; + let y = if top_to_bottom { y } else { ht - 1 - y }; + let px = if mapped { + let byte = |i: usize| v.get(i).copied().unwrap_or(0); + let index = if bpp == 1 { usize::from(byte(0)) } else { usize::from(u16::from_le_bytes([byte(0), byte(1)])) }; + *index.checked_sub(map_first).and_then(|k| palette.get(k)).ok_or_else(|| err(name, "colour index outside the colour map"))? + } else { + layout.rgba(v) + }; + let o = (y * w + x) * 4; + if let Some(d) = rgba.get_mut(o..o + 4) { + d.copy_from_slice(&px); + } + } + // 32-bit files from writers that leave the attribute bits unset often store zero alpha + // everywhere: such a picture is opaque, not invisible + let alpha_used = match (layout, mapped) { + (Layout::Bgra32, false) => attr_bits != 0 || rgba.as_chunks::<4>().0.iter().any(|p| p[3] != 0), + _ => true, + }; + if !alpha_used { + rgba.as_chunks_mut::<4>().0.iter_mut().for_each(|p| p[3] = 255); + } + let has_alpha = rgba.as_chunks::<4>().0.iter().any(|p| p[3] != 255); + Ok(Raster { width: w as u32, height: ht as u32, rgba, has_alpha }) +} + +/// Expand run-length packets into `pixels` stored values of `bpp` bytes each. Packets may span +/// scan lines (the TGA 2.0 spec discourages it; older writers do it). +fn unpack_rle(name: &str, mut data: &[u8], pixels: usize, bpp: usize) -> Result> { + // a two-byte packet expands to at most 128 values: a header asking for more is lying + let bound = data.len().saturating_mul(64).saturating_add(128); + if pixels > bound { + return Err(err(name, "truncated run-length data")); + } + let total = pixels.checked_mul(bpp).ok_or_else(|| err(name, "picture too large"))?; + let mut out = Vec::with_capacity(total); + while out.len() < total { + let (&head, rest) = data.split_first().ok_or_else(|| err(name, "truncated run-length data"))?; + let count = usize::from(head & 0x7F) + 1; + let want = (total - out.len()).min(count * bpp); + if head & 0x80 != 0 { + let v = rest.get(..bpp).ok_or_else(|| err(name, "truncated run-length data"))?; + for _ in 0..want / bpp { + out.extend_from_slice(v); + } + data = rest.get(bpp..).unwrap_or_default(); + } else { + let n = count * bpp; + let v = rest.get(..n).ok_or_else(|| err(name, "truncated run-length data"))?; + out.extend_from_slice(v.get(..want).unwrap_or(v)); + data = rest.get(n..).unwrap_or_default(); + } + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn header(kind: u8, w: u16, h: u16, depth: u8, descriptor: u8) -> Vec { + let mut b = vec![0, 0, kind, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + b.extend_from_slice(&w.to_le_bytes()); + b.extend_from_slice(&h.to_le_bytes()); + b.extend_from_slice(&[depth, descriptor]); + b + } + + #[test] + fn bottom_up_bgr_is_flipped() { + let mut f = header(2, 2, 2, 24, 0); + // bottom row first: blue, green; then top row: red, white + f.extend_from_slice(&[255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255]); + let r = decode("a.tga", &f).unwrap(); + assert_eq!((r.width, r.height, r.has_alpha), (2, 2, false)); + assert_eq!(r.rgba, [255, 0, 0, 255, 255, 255, 255, 255, 0, 0, 255, 255, 0, 255, 0, 255]); + } + + #[test] + fn rle_runs_and_raw_packets_cross_lines() { + let mut f = header(10, 3, 2, 32, 0x28); + // a run of 4 translucent red, then 2 raw pixels + f.extend_from_slice(&[0x83, 0, 0, 255, 128, 0x01, 1, 2, 3, 255, 4, 5, 6, 255]); + let r = decode("a.tga", &f).unwrap(); + assert!(r.has_alpha); + assert_eq!(&r.rgba[..16], &[255, 0, 0, 128].repeat(4)[..]); + assert_eq!(&r.rgba[16..], &[3, 2, 1, 255, 6, 5, 4, 255]); + } + + #[test] + fn colour_map_and_16_bit_alpha() { + let mut f = header(1, 2, 1, 8, 0x20); + f[1] = 1; // colour map present + f[3..5].copy_from_slice(&1u16.to_le_bytes()); // first entry index 1 + f[5..7].copy_from_slice(&2u16.to_le_bytes()); + f[7] = 24; + f.extend_from_slice(&[10, 20, 30, 40, 50, 60, 2, 1]); + let r = decode("a.tga", &f).unwrap(); + assert_eq!(r.rgba, [60, 50, 40, 255, 30, 20, 10, 255]); + f[18 + 6] = 0; // index 0 is below the map's first entry + assert!(decode("a.tga", &f).is_err()); + + let mut f = header(2, 2, 1, 16, 0x21); + f.extend_from_slice(&0xFC00u16.to_le_bytes()); // opaque full red + f.extend_from_slice(&0x03E0u16.to_le_bytes()); // transparent full green + let r = decode("a.tga", &f).unwrap(); + assert_eq!(r.rgba, [255, 0, 0, 255, 0, 255, 0, 0]); + } + + #[test] + fn zero_alpha_32_bit_without_attribute_bits_is_opaque() { + let mut f = header(2, 1, 1, 32, 0); + f.extend_from_slice(&[1, 2, 3, 0]); + let r = decode("a.tga", &f).unwrap(); + assert_eq!((r.rgba.as_slice(), r.has_alpha), (&[3, 2, 1, 255][..], false)); + } + + #[test] + fn hostile_headers_fail_without_allocating() { + // 65535 × 65535 RLE from a few bytes, truncated raw data, bad types + let mut f = header(10, 65535, 65535, 32, 0); + f.extend_from_slice(&[0xFF, 1, 2, 3, 4]); + assert!(decode("a.tga", &f).is_err()); + let mut f = header(2, 4, 4, 24, 0); + f.extend_from_slice(&[0; 10]); + assert!(decode("a.tga", &f).is_err()); + assert!(decode("a.tga", &header(2, 1, 1, 7, 0)).is_err()); + assert!(decode("a.tga", &header(1, 1, 1, 8, 0)).is_err()); + assert!(decode("a.tga", &[0; 5]).is_err()); + for n in 0..40 { + let mut f = header(9, 3, 3, 8, 0); + f[1] = 1; + f[5] = 2; + f[7] = 16; + f.extend_from_slice(&[0x82, 1, 0, 1, 0x01, 0, 0]); + f.truncate(n); + let _ = decode("a.tga", &f); + } + } + + #[test] + fn sniffs_by_footer_or_name() { + let mut f = header(2, 1, 1, 24, 0); + f.extend_from_slice(&[0, 0, 0]); + assert!(sniff("x.TGA", &f)); + assert!(!sniff("x.bin", &f)); + f.extend_from_slice(&[0; 8]); + f.extend_from_slice(FOOTER); + assert!(sniff("x.bin", &f)); + assert!(!sniff("x.tga", b"\x89PNG\r\n\x1a\n0000000000")); + } +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b62d7853..2ac9b655 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -56,8 +56,8 @@ only). "Tested" names the evidence. | WebP | — | yes | no | ahead | | **PSD** (layers as sequence) | read (layered) | no | — | 0% | | **OpenEXR** | read + write | no | sequences (half float, linear RGB / premultiplied RGBA, uncompressed; #806) | 35% | -| **DPX** | read + write | no | sequences (10-bit RGB, BT.709; #779) | 40% | -| Targa | read + write | no | sequences (24-bit, 32-bit with alpha; #779) | 40% | +| **DPX** | read + write | yes: luma / RGB / RGBA, 8–16-bit, filled 10/12-bit, either byte order (#821); no packed 10/12-bit, RLE or Y'CbCr | sequences (10-bit RGB, BT.709; #779) | 80% | +| Targa | read + write | yes: every image type, RLE, colour maps, 15–32-bit (#821) | sequences (24-bit, 32-bit with alpha; #779) | 95% | | HEIF / HEIC | read | no | — | 0% | | Radiance HDR, AI / EPS, ICO, PTL | read | no | — | 0% | @@ -84,7 +84,7 @@ only). "Tested" names the evidence. ## Summary **File formats dimension: ~60%, 140–240 h** (codecs counted separately). The beta blocker is -`.prproj`. After it, by user impact: AVI and WMV import, EXR / DPX / PSD stills, AAF / OMF +`.prproj`. After it, by user impact: AVI and WMV import, EXR / PSD stills, AAF / OMF validation in Avid and Pro Tools, DVD / Blu-ray / DCP / broadcast MXF delivery. ## Revision history diff --git a/docs/gaps.md b/docs/gaps.md index 83fb12b3..8b4b1bb5 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -165,9 +165,10 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** substreams (played as the 5.1 core), AVI (#598; listed as an extension but no demuxer), WMV / ASF, DV / DVCPRO / DV100, MPEG-4 Part 2, FLAC in MP4 (#603), camera RAW (R3D incl. R3D NE, ARRIRAW, Sony RAW / X-OCN, Canon RAW, BRAW, ProRes RAW; #345, #383), JPEG 2000 / - JPEG XS MXF, Cinema DNG; stills: PSD, EXR, DPX, Targa, HEIF / HEIC, Radiance HDR, AI / EPS; - transparent video (#402); Sony start timecode in `rtmd` (#460); stereo 3D flag (#500); MKV - multi-audio (#601). + JPEG XS MXF, Cinema DNG; stills: PSD, EXR, HEIF / HEIC, Radiance HDR, AI / EPS, and the rarer + DPX layouts (packed 10/12-bit, run-length, Y'CbCr; Targa and DPX stills and sequences import + since #821, deeper than 8 bits kept at 8); transparent video (#402); Sony start timecode in + `rtmd` (#460); stereo 3D flag (#500); MKV multi-audio (#601). - **Estimate:** 120–200 h (camera RAW alone 60–100 h, and some SDKs have licences we cannot use: clean-room from public specs only where specs exist). Docs: [codec-parity.md](codec-parity.md), [file-format-parity.md](file-format-parity.md). From f114b5f10c73795e0bd57dbc2220174d293f7542 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 19:01:15 -0400 Subject: [PATCH 143/144] Revert #783 (Linux H.265 through NVENC): intermittent heap corruption in the CLI With NVENC registered on Linux, `filmcraft-cli --test cli` aborts in 2 of 45 runs on an RTX 3090 (`malloc(): mismatching next->prev_size`, `malloc(): unaligned fastbin chunk detected`) right after an export finishes; 0 of 30 runs fail with this reverted. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +-- crates/export/src/settings.rs | 2 +- crates/platform/README.md | 24 +++++------ crates/platform/src/lib.rs | 42 ++++++++----------- crates/platform/src/nvenc/export.rs | 5 ++- crates/platform/src/nvenc/mod.rs | 4 +- crates/platform/tests/nvenc_hevc.rs | 2 +- crates/platform/tests/nvenc_hevc_export.rs | 2 +- .../platform/tests/nvenc_hevc_hdr_export.rs | 2 +- crates/platform/tests/nvenc_hevc_main10.rs | 2 +- crates/platform/tests/nvenc_hevc_probe.rs | 2 +- crates/platform/tests/nvenc_hevc_warm.rs | 2 +- crates/platform/tests/nvenc_rgba_input.rs | 4 +- docs/codec-parity.md | 2 +- docs/target-app-parity.md | 4 +- docs/testing.md | 2 +- 16 files changed, 51 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index 81968754..e74820ab 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,7 @@ No FFmpeg inside. The video codecs, AAC, Opus and the containers are our own Rus | **Matroska / WebM** | ✓ | | Lacing, Cues, header stripping, HDR colour metadata | | **Stills** | ✓ | ✓ | Import PNG, JPEG, GIF, WebP, TIFF and BMP; 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 and Linux, VideoToolbox on macOS) ([#30](https://github.com/storytold/filmcraft/issues/30)). +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)).
@@ -289,10 +289,10 @@ FilmCraft is in **alpha**. We track two numbers ([docs/target-app-parity.md](doc The biggest gaps today: -- **Speed on big footage.** Hardware decoding works on macOS and Windows, and for H.264 and HEVC on Linux (VA-API, or NVDEC with NVIDIA's driver; VP9 and AV1 decode in software there). Blend modes and the most common effects run on the GPU, but Lumetri, keys and export rendering still run on the CPU; H.264 encoding can use the hardware encoder on macOS and on Windows and Linux with an NVIDIA GPU (opt-in, Export ▸ Hardware encoding), and H.265 export is hardware-only on all three (HDR sequences export Main 10 PQ / HLG with NVENC; VideoToolbox writes 8-bit SDR) ([#30](https://github.com/storytold/filmcraft/issues/30)). +- **Speed on big footage.** Hardware decoding works on macOS and Windows, and for H.264 and HEVC on Linux (VA-API, or NVDEC with NVIDIA's driver; VP9 and AV1 decode in software there). Blend modes and the most common effects run on the GPU, but Lumetri, keys and export rendering still run on the CPU; H.264 encoding can use the hardware encoder on macOS and on Windows and Linux with an NVIDIA GPU (opt-in, Export ▸ Hardware encoding), and H.265 export is hardware-only on both (HDR sequences export Main 10 PQ / HLG with NVENC; VideoToolbox writes 8-bit SDR) ([#30](https://github.com/storytold/filmcraft/issues/30)). - **Premiere projects.** `.prproj` files can't be opened yet; bring sequences over as FCP7 XML, AAF or OTIO. - **No plugins.** No VST3 / Audio Units or OpenFX hosting. -- **Delivery codecs.** H.264 is our only software delivery-codec export; H.265 exports only through a hardware encoder (NVIDIA on Windows and Linux: Main 8-bit SDR and Main 10 HDR PQ / HLG; VideoToolbox on macOS: 8-bit Main, SDR); no AV1 export yet. +- **Delivery codecs.** H.264 is our only software delivery-codec export; H.265 exports only through a hardware encoder (NVIDIA on Windows: Main 8-bit SDR and Main 10 HDR PQ / HLG; VideoToolbox on macOS: 8-bit Main, SDR); no AV1 export yet. - **Real-world media and platforms.** Our decoders are bit-exact on conformance streams, but camera and phone files in the wild are less tested. Windows and Linux get far less testing than macOS. - **AI features.** Few so far; speech to text is optional and off by default. diff --git a/crates/export/src/settings.rs b/crates/export/src/settings.rs index 162c958a..ed0a928b 100644 --- a/crates/export/src/settings.rs +++ b/crates/export/src/settings.rs @@ -62,7 +62,7 @@ impl H264Profile { } /// Export ▸ Hardware encoding: whether H.264 may be encoded by the system's hardware video encoder -/// (VideoToolbox on macOS, NVENC on NVIDIA GPUs on Windows and Linux) instead of FilmCraft's own encoder. Off +/// (VideoToolbox on macOS, NVENC on NVIDIA GPUs on Windows) instead of FilmCraft's own encoder. Off /// by default: hardware encoders make different streams, and exports are otherwise byte-identical /// from run to run and machine to machine. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/platform/README.md b/crates/platform/README.md index cefdfa0d..b78c5773 100644 --- a/crates/platform/README.md +++ b/crates/platform/README.md @@ -61,7 +61,7 @@ let availability = filmcraft_platform::register(); // Available("VideoToolbox") - After a `flush` (which drains the MFT) the MFT only restarts at an IDR picture; the GOP cache always seeks after a flush, and a caller that continues from the middle of a GOP gets an error that `HybridDecoder` answers by replaying the run in software. -- **Windows and 64-bit Linux: NVIDIA NVENC H.264 and H.265 (HEVC) encoding** (`nvenc/`), 8-bit SDR 4:2:0 for Export. The driver's +- **Windows and 64-bit Linux: NVIDIA NVENC H.264 encoding, and H.265 (HEVC) on Windows** (`nvenc/`), 8-bit SDR 4:2:0 for Export. The driver's `nvEncodeAPI64.dll` (Windows) or `libnvidia-encode.so.1` (Linux), API 12.1, is loaded at run time, so machines without NVIDIA still start. Windows uses a Direct3D 11 device; Linux retains a primary CUDA context through the runtime-loaded `libcuda.so.1`, released after the encoder is destroyed. @@ -76,8 +76,8 @@ let availability = filmcraft_platform::register(); // Available("VideoToolbox") It declines two-pass VBR, HDR, MXF, interlaced output, sizes outside NVENC's limits and systems without an NVIDIA GPU or driver, and the software encoder runs instead. A failure during an export ends it with an error, since a hardware stream cannot be finished in software. The same session, - ring and input path encode H.265, see [Hardware H.265 (HEVC) encoding - (NVENC)](#hardware-h265-hevc-encoding-nvenc). + ring and input path encode H.265, see [Hardware H.265 (HEVC) encoding (Windows, + NVENC)](#hardware-h265-hevc-encoding-windows-nvenc). - **Linux: NVDEC, H.264 (`avcC`) and HEVC (`hvcC`)** on NVIDIA's proprietary driver (`nvdec/`): H.264 8-bit and HEVC 8- / 10-bit, 4:2:0 progressive. `libnvcuvid.so.1` and `libcuda.so.1` are loaded at run time; without them (or without a device) nothing is registered. NVIDIA's own parser takes the @@ -188,7 +188,7 @@ The sample entry's `hvcC` record is the one VideoToolbox wrote for the stream (r format description's sample description extension atoms, with the VPS / SPS / PPS), so profile, level and flags are the encoder's own. If it is missing the export stops with that reason. -## Hardware H.265 (HEVC) encoding (NVENC, Windows and Linux) +## Hardware H.265 (HEVC) encoding (Windows, NVENC) One codec enum (`nvenc::Codec`, chosen by `Profile::HevcMain` the way `VtProfile::HevcMain` does it for VideoToolbox) parameterises the NVENC session: the codec and profile GUIDs, the capability @@ -295,13 +295,13 @@ level 4 High tier, which many hardware decoders refuse; it is now level 4.1 Main | `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 / Linux, 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 / Linux, 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 / Linux, 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 / Linux) | 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 / Linux) | `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 / Linux, 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 / Linux, 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 | +| `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/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/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 | | `crates/export/src/hdr_tests.rs` | `rgbf_to_yuv420_10`: exact codes for black, white, grey and the BT.2020 primaries and secondaries, NaN / infinity / out-of-range, odd sizes, wrong lengths and overflowing sizes; the HDR probe registry; which exports are HDR (HEVC SDR without a probe, HDR with one, `settings.sdr` forcing SDR); the SEI of the software H.264 encoder equals `ColorSignal::static_metadata` | @@ -344,7 +344,7 @@ Zero-copy upload of decoded pictures into wgpu textures (`CVPixelBuffer`s on mac textures on Windows); B-frames in the VideoToolbox encoders and 10-bit / HDR HEVC (Main 10) in VideoToolbox; VP9 and AV1 through VA-API (Linux), and VA-API encoding; field-coded H.264; 4:4:4 and 4:2:2 encoding with NVENC, HDR H.264 with NVENC (HDR H.264 stays with the software encoder), HDR10 MaxCLL / MaxFALL measured from -the pictures (they are written as 0, unknown), AV1 with NVENC, H.265 with the other Windows and Linux vendors, and encoders +the pictures (they are written as 0, unknown), AV1 with NVENC, H.265 with the other Windows vendors and on Linux, and encoders from other vendors on Windows (through Media Foundation); VP9 / AV1 4:4:4 and 12-bit on Windows. ### Linux NVENC sources and limits diff --git a/crates/platform/src/lib.rs b/crates/platform/src/lib.rs index e42db755..8744da0e 100644 --- a/crates/platform/src/lib.rs +++ b/crates/platform/src/lib.rs @@ -8,7 +8,7 @@ //! Linux with neither) registration does nothing and reports [`Availability::Unavailable`]. It also registers a //! hardware H.264 encoder factory (`filmcraft_export::register_encoder`) that only acts when an //! export asks for it (`ExportSettings::hardware_encoding` = `Auto`), see [`hardware_encode`]; on -//! macOS and with NVENC ([`nvenc`], Windows and Linux) it also makes the H.265 export format available, and with NVENC +//! macOS and on Windows (NVENC, [`nvenc`]) it also makes the H.265 export format available, and on Windows //! (Main 10) lets HDR sequences export as HDR H.265. //! //! Hardware decoding never makes a file undecodable: @@ -68,6 +68,8 @@ pub enum Availability { /// harmless). Streams they do not take, and every stream while hardware decoding is Off, keep /// using FilmCraft's own decoders. pub fn register() -> Availability { + #[cfg(all(target_os = "linux", target_pointer_width = "64"))] + filmcraft_export::register_encoder(nvenc::export::factory); #[cfg(target_os = "macos")] { filmcraft_codecs::register_video_decoder(videotoolbox_factory); @@ -79,9 +81,23 @@ pub fn register() -> Availability { filmcraft_codecs::hw::set_hw_backend("VideoToolbox"); Availability::Available("VideoToolbox") } + // Export ▸ H.265: NVENC is FilmCraft's only HEVC encoder on Windows, so the format is available + // when a small HEVC session opens. That takes about half a second, so it is asked on a thread of + // its own now rather than by the first draw of the format list. #[cfg(target_os = "windows")] { - register_nvenc(); + filmcraft_export::register_format_probe(filmcraft_export::Format::Hevc, nvenc::hevc_available); + // HDR sequences export as HEVC Main 10 (PQ / HLG) only where NVENC has a 10-bit encoder; elsewhere + // (and on macOS, whose VideoToolbox path is 8-bit) they are tone-mapped to SDR, as before + filmcraft_export::register_hdr_probe(filmcraft_export::Format::Hevc, nvenc::hevc_hdr_available); + nvenc::warm_hevc_probe(); + } + #[cfg(target_os = "windows")] + { + static ENCODERS: std::sync::Once = std::sync::Once::new(); + // Export ▸ Hardware encoding (NVENC H.264): in front of the software encoder, taking an + // export only when asked for and when NVENC can do it + ENCODERS.call_once(|| filmcraft_export::register_encoder(nvenc::export::factory)); filmcraft_codecs::register_video_decoder(media_foundation_factory); filmcraft_codecs::hw::set_hw_backend("Media Foundation"); Availability::Available("Media Foundation") @@ -114,11 +130,6 @@ pub fn register() -> Availability { } Err(why) => log::info!("no NVDEC hardware decoding: {why}"), } - // After the VA-API probe, never beside it: libva-nvidia-driver calls `cuInit` from its - // library constructor (under the loader lock) while a first `cuInit` on another thread - // loads libraries under CUDA's own lock, and the two wait for each other forever. - #[cfg(target_pointer_width = "64")] - register_nvenc(); available } #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] @@ -127,23 +138,6 @@ pub fn register() -> Availability { } } -/// NVENC: the Export encoder factory, and what makes the H.265 format (and HDR H.265) available. -#[cfg(any(target_os = "windows", all(target_os = "linux", target_pointer_width = "64")))] -fn register_nvenc() { - // Export ▸ H.265: NVENC is FilmCraft's only HEVC encoder on Windows and Linux, so the format is available - // when a small HEVC session opens. That takes about half a second, so it is asked on a thread of - // its own now rather than by the first draw of the format list. - filmcraft_export::register_format_probe(filmcraft_export::Format::Hevc, nvenc::hevc_available); - // HDR sequences export as HEVC Main 10 (PQ / HLG) only where NVENC has a 10-bit encoder; elsewhere - // (and on macOS, whose VideoToolbox path is 8-bit) they are tone-mapped to SDR, as before - filmcraft_export::register_hdr_probe(filmcraft_export::Format::Hevc, nvenc::hevc_hdr_available); - nvenc::warm_hevc_probe(); - static ENCODERS: std::sync::Once = std::sync::Once::new(); - // Export ▸ Hardware encoding (NVENC H.264): in front of the software encoder, taking an - // export only when asked for and when NVENC can do it - ENCODERS.call_once(|| filmcraft_export::register_encoder(nvenc::export::factory)); -} - /// Whether [`register`] has put a hardware decoder factory in front of our decoders. pub fn registered() -> bool { #[cfg(target_os = "macos")] diff --git a/crates/platform/src/nvenc/export.rs b/crates/platform/src/nvenc/export.rs index a796742f..4344cb52 100644 --- a/crates/platform/src/nvenc/export.rs +++ b/crates/platform/src/nvenc/export.rs @@ -211,7 +211,8 @@ fn config(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettings) - /// The Export encoder factory (see the module documentation). pub fn factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -> Option>> { if format == Format::Hevc { - return hevc_factory(w, h, rate, s); + // H.265 through NVENC is Windows-only for now; on Linux this backend takes H.264 only + return if cfg!(target_os = "windows") { hevc_factory(w, h, rate, s) } else { None }; } factory_with(format, w, h, rate, s, |cfg| open(cfg, &Signal::default())) } @@ -260,7 +261,7 @@ fn factory_with( } } -/// The H.265 side of [`factory`]: FilmCraft's only HEVC encoder on Windows and Linux, so the Hardware encoding toggle +/// The H.265 side of [`factory`]: FilmCraft's only HEVC encoder on Windows, so the Hardware encoding toggle /// does not apply and a request NVENC cannot take is an error, not a fall-through. fn hevc_factory(w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -> Option>> { let declined = |why: &str| { diff --git a/crates/platform/src/nvenc/mod.rs b/crates/platform/src/nvenc/mod.rs index d15928e1..4fe2357f 100644 --- a/crates/platform/src/nvenc/mod.rs +++ b/crates/platform/src/nvenc/mod.rs @@ -1,5 +1,5 @@ -//! NVIDIA NVENC hardware encoding: H.264 and H.265 (HEVC) Main / Main 10 (Windows and -//! 64-bit Linux). +//! NVIDIA NVENC hardware encoding: H.264 (Windows and 64-bit Linux) and H.265 (HEVC) Main / Main 10 +//! (Windows). //! //! The encoder runs on the GPU's NVENC engine through the driver's `nvEncodeAPI64.dll` on Windows //! (on a Direct3D 11 device) or `libnvidia-encode.so.1` on Linux (on the CUDA driver's primary diff --git a/crates/platform/tests/nvenc_hevc.rs b/crates/platform/tests/nvenc_hevc.rs index d5f1ca9d..acd2bee9 100644 --- a/crates/platform/tests/nvenc_hevc.rs +++ b/crates/platform/tests/nvenc_hevc.rs @@ -2,7 +2,7 @@ //! with our own HEVC decoder to pictures close to the source, keyframes and timestamps are right, the //! samples carry no parameter sets and the `hvcC` describes the SPS it was built from. Skips //! without an NVIDIA GPU / driver with HEVC encoding. -#![cfg(any(target_os = "windows", all(target_os = "linux", target_pointer_width = "64")))] +#![cfg(target_os = "windows")] use filmcraft_bitstream::unescape_rbsp; use filmcraft_hevc::params::Sps; diff --git a/crates/platform/tests/nvenc_hevc_export.rs b/crates/platform/tests/nvenc_hevc_export.rs index 1b449188..95f074bb 100644 --- a/crates/platform/tests/nvenc_hevc_export.rs +++ b/crates/platform/tests/nvenc_hevc_export.rs @@ -4,7 +4,7 @@ //! counters; and what NVENC declines (an error that says why, counted, never a crash). Its own test //! binary: the encoder registry and the counters are process-wide. Skips without an NVIDIA GPU with //! an HEVC encoder. -#![cfg(any(target_os = "windows", all(target_os = "linux", target_pointer_width = "64")))] +#![cfg(target_os = "windows")] use std::path::Path; use std::process::Command; diff --git a/crates/platform/tests/nvenc_hevc_hdr_export.rs b/crates/platform/tests/nvenc_hevc_hdr_export.rs index 157e536e..8acbc1ca 100644 --- a/crates/platform/tests/nvenc_hevc_hdr_export.rs +++ b/crates/platform/tests/nvenc_hevc_hdr_export.rs @@ -5,7 +5,7 @@ //! our importer reads back as PQ / HLG, 10-bit; whose pixels follow a ProRes HDR export of the same //! sequence. SDR stays Main 8-bit BT.709, `settings.sdr` forces it. Its own test binary: the encoder //! registry and the counters are process-wide. Skips (SKIPPED) only without a 10-bit HEVC encoder. -#![cfg(any(target_os = "windows", all(target_os = "linux", target_pointer_width = "64")))] +#![cfg(target_os = "windows")] use std::path::Path; use std::process::Command; diff --git a/crates/platform/tests/nvenc_hevc_main10.rs b/crates/platform/tests/nvenc_hevc_main10.rs index 74a8e280..76d0c9cc 100644 --- a/crates/platform/tests/nvenc_hevc_main10.rs +++ b/crates/platform/tests/nvenc_hevc_main10.rs @@ -3,7 +3,7 @@ //! pictures that come out of our own HEVC decoder are 10-bit and close to them (smooth ramps keep far more //! than 256 levels, bright code values survive), the VUI and the HDR10 SEI messages are right, and //! hostile inputs are errors. Skips (with SKIPPED) only on a machine without a 10-bit HEVC encoder. -#![cfg(any(target_os = "windows", all(target_os = "linux", target_pointer_width = "64")))] +#![cfg(target_os = "windows")] use filmcraft_bitstream::unescape_rbsp; use filmcraft_export::{ColorSignal, rgbf_to_yuv420_10}; diff --git a/crates/platform/tests/nvenc_hevc_probe.rs b/crates/platform/tests/nvenc_hevc_probe.rs index 28f28f34..e2a41cd7 100644 --- a/crates/platform/tests/nvenc_hevc_probe.rs +++ b/crates/platform/tests/nvenc_hevc_probe.rs @@ -1,7 +1,7 @@ //! The HEVC availability probe of the NVENC backend: it opens one small encoder session, once. Its own //! test binary so that "first call" means a fresh process (driver load, Direct3D 11 device, NVENC //! session and encoder initialisation all included). -#![cfg(any(target_os = "windows", all(target_os = "linux", target_pointer_width = "64")))] +#![cfg(target_os = "windows")] use std::time::{Duration, Instant}; diff --git a/crates/platform/tests/nvenc_hevc_warm.rs b/crates/platform/tests/nvenc_hevc_warm.rs index 1a616328..adc0ebb2 100644 --- a/crates/platform/tests/nvenc_hevc_warm.rs +++ b/crates/platform/tests/nvenc_hevc_warm.rs @@ -1,7 +1,7 @@ //! `register` asks the NVENC HEVC probe on a thread of its own, so the first draw of Export's format //! list does not open an encoder session on the UI thread. Its own test binary: anything else asking //! `hevc_available` first would make the wait meaningless. -#![cfg(any(target_os = "windows", all(target_os = "linux", target_pointer_width = "64")))] +#![cfg(target_os = "windows")] use std::time::{Duration, Instant}; diff --git a/crates/platform/tests/nvenc_rgba_input.rs b/crates/platform/tests/nvenc_rgba_input.rs index 62b8b9bd..4009f3e6 100644 --- a/crates/platform/tests/nvenc_rgba_input.rs +++ b/crates/platform/tests/nvenc_rgba_input.rs @@ -1,7 +1,7 @@ -//! NVENC with packed RGBA input (Windows and Linux, NVIDIA GPU): the GPU's RGB → 4:2:0 conversion matches the +//! NVENC with packed RGBA input (Windows, NVIDIA GPU): the GPU's RGB → 4:2:0 conversion matches the //! export's own BT.709 limited-range conversion (`filmcraft_export::rgba_to_yuv420_8`), for HEVC Main //! and H.264. Skips without an NVIDIA GPU / driver. -#![cfg(any(target_os = "windows", all(target_os = "linux", target_pointer_width = "64")))] +#![cfg(target_os = "windows")] use filmcraft_isobmff::{AvcConfig, SampleEntry}; use filmcraft_platform::nvenc::{Codec, Config, Nvenc, Packet, Profile, Signal, available, hevc_available}; diff --git a/docs/codec-parity.md b/docs/codec-parity.md index 8fe1ff6b..8aaa7655 100644 --- a/docs/codec-parity.md +++ b/docs/codec-parity.md @@ -16,7 +16,7 @@ contents). Percent is ready-for-real-work for that codec: depth, profiles, speed | Codec | Premiere | Our decode | Our encode | Fidelity and tests | % | Est. | |---|---|---|---|---|---|---| | 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 and Linux); no software encoder | NVENC round trip 48.9 dB (8-bit), 71.1 dB (10-bit); ffprobe | 60% | 30–60 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 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 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e74879f6..13bb67a4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -139,8 +139,8 @@ Evidence for the cross-cutting rows: - **Hardware ~45%.** Hardware decode exists on all three desktop OSs (VideoToolbox; Media Foundation / D3D11 incl. VP9 and AV1; VA-API and NVDEC for H.264 / HEVC on Linux). Hardware - encode: VideoToolbox H.264 / HEVC 8-bit; NVENC H.264 and HEVC incl. Main 10 HDR (Windows, - Linux). Missing against Premiere: Intel Quick Sync and AMD encoders, 10-bit HEVC on macOS, + encode: VideoToolbox H.264 / HEVC 8-bit; NVENC H.264 (Windows, Linux) and HEVC incl. Main 10 HDR + (Windows). Missing against Premiere: Intel Quick Sync and AMD encoders, 10-bit HEVC on macOS, ProRes hardware, zero-copy upload, ~48 of Premiere's accelerated effects, external video I/O, control surfaces. Detail: [hardware-parity.md](hardware-parity.md). - **Performance ~55%.** 4K H.264 / HEVC play with no dropped frames with hardware decoding (M4 diff --git a/docs/testing.md b/docs/testing.md index a35f48b4..856dad73 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -77,7 +77,7 @@ are never linked or shipped ([AGENTS.md](../AGENTS.md) §2). The NVENC tests (`crates/platform/tests/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 and Linux) skip, printing `SKIPPED`, without an NVIDIA GPU +`nvenc_hevc_hdr_export.rs` for Main 10 HDR; Windows only) skip, printing `SKIPPED`, without an NVIDIA GPU with NVENC (H.265: when the HEVC probe says there is no HEVC encoder; Main 10: when the Main 10 probe says there is no 10-bit encoder; on a machine that has one, a configuration NVENC refuses fails the test). The FFI layout tests in `crates/platform/src/nvenc/abi_tests.rs` were generated from a C program built with MSVC From 2ff3f5e266838700c292a35aaf762e3d4dcef980 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 19:28:41 -0400 Subject: [PATCH 144/144] Release prep: 0.6.0 and a Linux riscv64 release build (#830) - release.yml: new `linux-riscv64` job, cross-compiled on ubuntu-24.04 against ports.ubuntu.com riscv64 libs (ported from GridCraft), tar.gz only, CLI smoke-tested under QEMU. Installs libasound2-dev:riscv64 for cpal/ALSA; the VA-API and NVDEC/NVENC paths are loaded with libloading at run time, so they need no riscv64 libraries. The draft-release job now needs it, so its tarball is uploaded and covered by SHA256SUMS.txt. - packaging/linux/package.sh: CROSS_ARCH / CROSS_TARGET / CROSS_COMPILE / EMULATOR support, as in GridCraft. - README and docs/releasing.md list the riscv64 tarball. - cargo xtask version set 0.6.0 (Cargo.toml, Cargo.lock); refresh contributors. Co-authored-by: Claude Opus 5.5 --- .github/workflows/release.yml | 82 ++++- Cargo.lock | 98 +++--- Cargo.toml | 2 +- README.md | 3 + contributors/contributors.json | 614 +++++++++++++++++++++++++++++---- docs/releasing.md | 1 + packaging/linux/package.sh | 27 +- 7 files changed, 693 insertions(+), 134 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 08eba22b..0ebde2f4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,9 +4,9 @@ # The jobs that sign (macOS, Windows) and the one that publishes the draft run in the `release` # environment (restricted to the `release` branch), which holds the signing secrets. Every secret # is optional: missing ones produce unsigned artifacts and a warning, never a failed build. The -# jobs that sign nothing (version, Linux, Flatpak, FreeBSD, web) get no environment and no -# secrets, so a dispatch on any other branch dry-runs them; there the signing jobs are refused, -# so the draft-release job (which needs them) is skipped and nothing is published. +# jobs that sign nothing (version, Linux incl. riscv64, Flatpak, FreeBSD, web) get no environment +# and no secrets, so a dispatch on any other branch dry-runs them; there the signing jobs are +# refused, so the draft-release job (which needs them) is skipped and nothing is published. name: Release on: @@ -231,6 +231,80 @@ jobs: path: dist/release/* if-no-files-found: error + linux-riscv64: + name: Linux riscv64 + needs: version + # Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release + # from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's + # riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the + # CLI is smoke-tested under QEMU. Audio links ALSA (cpal), hence libasound2-dev:riscv64; the + # hardware video paths (VA-API, NVDEC/NVENC) are loaded at run time with libloading, so they + # need no riscv64 libraries and fall back to the pure-Rust codecs when absent. + runs-on: ubuntu-24.04 + # No `environment: release`: this job signs nothing, so it gets no release secrets. + env: + FILMCRAFT_VERSION: ${{ needs.version.outputs.version }} + FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: storytold/craft-fonts + ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately + path: craft-fonts + - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable + with: + targets: riscv64gc-unknown-linux-gnu + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + key: release-linux-riscv64 + - name: Apply version override + if: inputs.version != '' + run: cargo xtask version set "$FILMCRAFT_VERSION" + - name: Install cross-compilation dependencies + run: | + . /etc/os-release + sudo dpkg --add-architecture riscv64 + # Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports. + if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then + sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources + fi + if [ -f /etc/apt/sources.list ]; then + sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list + sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list + fi + for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do + echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse" + done | sudo tee /etc/apt/sources.list.d/riscv64.list + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \ + libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \ + libxi-dev:riscv64 libgl1-mesa-dev:riscv64 libasound2-dev:riscv64 + - name: Build and package (tar.gz) + env: + CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts + CRAFT_FONTS_REQUIRED: "1" + CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc + PKG_CONFIG_SYSROOT_DIR: / + PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig + PKG_CONFIG_ALLOW_CROSS: "1" + CROSS_ARCH: riscv64 + CROSS_TARGET: riscv64gc-unknown-linux-gnu + CROSS_COMPILE: riscv64-linux-gnu- + EMULATOR: qemu-riscv64 + QEMU_LD_PREFIX: /usr/riscv64-linux-gnu + run: packaging/linux/package.sh --formats tar + - name: Check the tarball + run: | + tar -tzf dist/release/filmcraft-*-linux-riscv64.tar.gz \ + | grep -E '/bin/filmcraft$|/bin/filmcraft-cli$|/share/applications/ai\.storyteller\.filmcraft\.desktop$' + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: release-linux-riscv64 + path: dist/release/*.tar.gz + if-no-files-found: error + flatpak: name: Flatpak ${{ matrix.arch }} needs: [version, linux] @@ -365,7 +439,7 @@ jobs: release: name: Draft GitHub Release - needs: [version, macos, windows, linux, flatpak, freebsd, web] + needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web] runs-on: ubuntu-latest environment: release permissions: diff --git a/Cargo.lock b/Cargo.lock index 5529201a..1db02133 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1665,7 +1665,7 @@ dependencies = [ [[package]] name = "filmcraft" -version = "0.5.0" +version = "0.6.0" dependencies = [ "arboard", "chrono", @@ -1697,7 +1697,7 @@ dependencies = [ [[package]] name = "filmcraft-aac" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -1707,7 +1707,7 @@ dependencies = [ [[package]] name = "filmcraft-ac3" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-testkit", "thiserror 2.0.21", @@ -1715,7 +1715,7 @@ dependencies = [ [[package]] name = "filmcraft-apv" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -1725,14 +1725,14 @@ dependencies = [ [[package]] name = "filmcraft-audio-dsp" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-testkit", ] [[package]] name = "filmcraft-automation" -version = "0.5.0" +version = "0.6.0" dependencies = [ "base64 0.22.1", "filmcraft-engine", @@ -1748,7 +1748,7 @@ dependencies = [ [[package]] name = "filmcraft-av1" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -1759,14 +1759,14 @@ dependencies = [ [[package]] name = "filmcraft-bitstream" -version = "0.5.0" +version = "0.6.0" dependencies = [ "proptest", ] [[package]] name = "filmcraft-captions" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-color", "filmcraft-project", @@ -1779,11 +1779,11 @@ dependencies = [ [[package]] name = "filmcraft-cfb" -version = "0.5.0" +version = "0.6.0" [[package]] name = "filmcraft-cli" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-automation", "filmcraft-codecs", @@ -1804,7 +1804,7 @@ dependencies = [ [[package]] name = "filmcraft-codecs" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-aac", "filmcraft-ac3", @@ -1838,7 +1838,7 @@ dependencies = [ [[package]] name = "filmcraft-color" -version = "0.5.0" +version = "0.6.0" dependencies = [ "proptest", "serde", @@ -1846,7 +1846,7 @@ dependencies = [ [[package]] name = "filmcraft-dnx" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -1856,7 +1856,7 @@ dependencies = [ [[package]] name = "filmcraft-edit" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-geom", "filmcraft-media", @@ -1869,7 +1869,7 @@ dependencies = [ [[package]] name = "filmcraft-engine" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-audio-dsp", "filmcraft-captions", @@ -1901,7 +1901,7 @@ dependencies = [ [[package]] name = "filmcraft-export" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-aac", "filmcraft-apv", @@ -1931,7 +1931,7 @@ dependencies = [ [[package]] name = "filmcraft-format" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-media", "filmcraft-project", @@ -1944,7 +1944,7 @@ dependencies = [ [[package]] name = "filmcraft-frame" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-color", "filmcraft-time", @@ -1954,7 +1954,7 @@ dependencies = [ [[package]] name = "filmcraft-geom" -version = "0.5.0" +version = "0.6.0" dependencies = [ "proptest", "serde", @@ -1962,7 +1962,7 @@ dependencies = [ [[package]] name = "filmcraft-golden" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-color", "filmcraft-frame", @@ -1979,7 +1979,7 @@ dependencies = [ [[package]] name = "filmcraft-gpu" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-color", "filmcraft-export", @@ -1998,7 +1998,7 @@ dependencies = [ [[package]] name = "filmcraft-h264" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -2008,7 +2008,7 @@ dependencies = [ [[package]] name = "filmcraft-h264enc" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -2018,7 +2018,7 @@ dependencies = [ [[package]] name = "filmcraft-hevc" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -2028,7 +2028,7 @@ dependencies = [ [[package]] name = "filmcraft-interchange" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-cfb", "filmcraft-color", @@ -2045,7 +2045,7 @@ dependencies = [ [[package]] name = "filmcraft-isobmff" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -2056,7 +2056,7 @@ dependencies = [ [[package]] name = "filmcraft-matroska" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-testkit", "serde_json", @@ -2064,7 +2064,7 @@ dependencies = [ [[package]] name = "filmcraft-media" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-color", "filmcraft-frame", @@ -2077,7 +2077,7 @@ dependencies = [ [[package]] name = "filmcraft-mpeg2v" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-testkit", "rayon", @@ -2086,7 +2086,7 @@ dependencies = [ [[package]] name = "filmcraft-mpegts" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-testkit", "thiserror 2.0.21", @@ -2094,28 +2094,28 @@ dependencies = [ [[package]] name = "filmcraft-mxf" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-testkit", ] [[package]] name = "filmcraft-ogg" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-testkit", ] [[package]] name = "filmcraft-opus" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-testkit", ] [[package]] name = "filmcraft-platform" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-bitstream", "filmcraft-codecs", @@ -2141,7 +2141,7 @@ dependencies = [ [[package]] name = "filmcraft-project" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-color", "filmcraft-geom", @@ -2155,7 +2155,7 @@ dependencies = [ [[package]] name = "filmcraft-prores" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -2165,7 +2165,7 @@ dependencies = [ [[package]] name = "filmcraft-render" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-audio-dsp", "filmcraft-captions", @@ -2185,7 +2185,7 @@ dependencies = [ [[package]] name = "filmcraft-scopes" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-color", "serde", @@ -2194,7 +2194,7 @@ dependencies = [ [[package]] name = "filmcraft-speech" -version = "0.5.0" +version = "0.6.0" dependencies = [ "candle-core", "candle-nn", @@ -2212,14 +2212,14 @@ dependencies = [ [[package]] name = "filmcraft-testkit" -version = "0.5.0" +version = "0.6.0" dependencies = [ "png", ] [[package]] name = "filmcraft-text" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-color", "harfrust", @@ -2231,7 +2231,7 @@ dependencies = [ [[package]] name = "filmcraft-time" -version = "0.5.0" +version = "0.6.0" dependencies = [ "proptest", "serde", @@ -2239,7 +2239,7 @@ dependencies = [ [[package]] name = "filmcraft-tts" -version = "0.5.0" +version = "0.6.0" dependencies = [ "candle-core", "candle-nn", @@ -2253,7 +2253,7 @@ dependencies = [ [[package]] name = "filmcraft-tts-text" -version = "0.5.0" +version = "0.6.0" dependencies = [ "serde", "thiserror 2.0.21", @@ -2261,7 +2261,7 @@ dependencies = [ [[package]] name = "filmcraft-ui-egui" -version = "0.5.0" +version = "0.6.0" dependencies = [ "cpu-time", "eframe", @@ -2298,7 +2298,7 @@ dependencies = [ [[package]] name = "filmcraft-vp9" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2307,7 +2307,7 @@ dependencies = [ [[package]] name = "filmcraft-web" -version = "0.5.0" +version = "0.6.0" dependencies = [ "eframe", "egui", @@ -7332,7 +7332,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-testkit", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 1d306cff..a8990aa7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/*", "xtask"] [workspace.package] -version = "0.5.0" +version = "0.6.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" diff --git a/README.md b/README.md index e74820ab..0ce67cb3 100644 --- a/README.md +++ b/README.md @@ -362,6 +362,9 @@ and web backend defaults are unchanged. | Fedora/RHEL/openSUSE | `filmcraft--linux-x86_64.rpm` | `filmcraft--linux-aarch64.rpm` | | | Tarball | `filmcraft--linux-x86_64.tar.gz` | `filmcraft--linux-aarch64.tar.gz` | Unpack and run `./install.sh` | +RISC-V (riscv64): `filmcraft--linux-riscv64.tar.gz` (tarball only; needs glibc 2.39+, e.g. Ubuntu 24.04 +or Debian 13). + The tarball installer places FilmCraft, its command-line tool and desktop integration in `~/.local`, without administrator permissions. For all users, run `sudo ./install.sh --prefix /usr/local` instead. Running the installer again updates the installation. diff --git a/contributors/contributors.json b/contributors/contributors.json index 29b65c4c..52531d0e 100644 --- a/contributors/contributors.json +++ b/contributors/contributors.json @@ -2,23 +2,23 @@ "schema": 1, "repo": "storytold/filmcraft", "branch": "main", - "head": "7b6c134472287db4367fca80bcb498240ba36567", - "head_date": "2026-10-09T15:02:38Z", - "commits": 539, + "head": "9efe8f3112194b58a30cf0449b569306077c67d4", + "head_date": "2026-10-10T22:34:28Z", + "commits": 812, "unattributed_commits": 0, "contributors": [ { "login": "echelon", "people_entry": true, "github_name": null, - "prs": 48, - "commits": 337, - "lines_added": 311167, - "lines_deleted": 13200, + "prs": 49, + "commits": 366, + "lines_added": 313219, + "lines_deleted": 14128, "binary_added": 74, "binary_deleted": 0, "first_commit": "2026-09-30T09:22:00Z", - "last_commit": "2026-10-09T15:02:38Z" + "last_commit": "2026-10-10T20:08:58Z" }, { "login": "storybored379", @@ -154,9 +154,9 @@ "login": "carlitosteso3", "people_entry": false, "github_name": null, - "prs": 15, - "commits": 15, - "lines_added": 1740, + "prs": 16, + "commits": 16, + "lines_added": 2009, "lines_deleted": 207, "binary_added": 0, "binary_deleted": 0, @@ -232,27 +232,53 @@ "login": "DWAA1660", "people_entry": false, "github_name": null, - "prs": 2, - "commits": 2, - "lines_added": 209, - "lines_deleted": 0, + "prs": 3, + "commits": 3, + "lines_added": 441, + "lines_deleted": 10, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-07T22:57:38Z", - "last_commit": "2026-10-08T23:52:30Z" + "last_commit": "2026-10-09T22:01:08Z" }, { "login": "txiki739", "people_entry": false, "github_name": null, - "prs": 5, - "commits": 8, - "lines_added": 7976, - "lines_deleted": 3333, + "prs": 8, + "commits": 11, + "lines_added": 8160, + "lines_deleted": 3347, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-07T23:41:48Z", - "last_commit": "2026-10-09T09:28:24Z" + "last_commit": "2026-10-09T14:06:49Z" + }, + { + "login": "mutombopr", + "people_entry": false, + "github_name": null, + "prs": 4, + "commits": 19, + "lines_added": 10421, + "lines_deleted": 337, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-08T03:57:26Z", + "last_commit": "2026-10-10T17:46:20Z" + }, + { + "login": "ahmedhosnypro", + "people_entry": true, + "github_name": null, + "prs": 2, + "commits": 5, + "lines_added": 473, + "lines_deleted": 80, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-08T05:32:08Z", + "last_commit": "2026-10-09T22:00:30Z" }, { "login": "wstock", @@ -284,14 +310,14 @@ "login": "MrCloudy2", "people_entry": false, "github_name": null, - "prs": 1, - "commits": 1, - "lines_added": 132, - "lines_deleted": 4, + "prs": 6, + "commits": 7, + "lines_added": 1293, + "lines_deleted": 46, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-08T08:53:54Z", - "last_commit": "2026-10-08T08:53:54Z" + "last_commit": "2026-10-10T15:14:34Z" }, { "login": "switch87", @@ -336,27 +362,27 @@ "login": "MarekSpodBiedry", "people_entry": false, "github_name": null, - "prs": 2, - "commits": 2, - "lines_added": 74, - "lines_deleted": 6, + "prs": 29, + "commits": 29, + "lines_added": 1048, + "lines_deleted": 137, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-08T13:44:56Z", - "last_commit": "2026-10-09T08:45:28Z" + "last_commit": "2026-10-10T21:22:52Z" }, { "login": "Tom1tk", "people_entry": false, "github_name": null, - "prs": 2, - "commits": 2, - "lines_added": 47, - "lines_deleted": 26, + "prs": 3, + "commits": 3, + "lines_added": 70, + "lines_deleted": 30, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-08T13:47:40Z", - "last_commit": "2026-10-08T14:56:39Z" + "last_commit": "2026-10-09T14:19:08Z" }, { "login": "Pathos0925", @@ -375,14 +401,14 @@ "login": "mjohnson7cd", "people_entry": false, "github_name": null, - "prs": 2, - "commits": 1, - "lines_added": 100, - "lines_deleted": 2, + "prs": 3, + "commits": 2, + "lines_added": 121, + "lines_deleted": 5, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-08T20:36:28Z", - "last_commit": "2026-10-08T20:36:28Z" + "last_commit": "2026-10-10T00:47:33Z" }, { "login": "Oldrich333", @@ -401,14 +427,14 @@ "login": "cshapiro", "people_entry": false, "github_name": null, - "prs": 3, - "commits": 5, - "lines_added": 3128, - "lines_deleted": 111, + "prs": 4, + "commits": 6, + "lines_added": 3165, + "lines_deleted": 116, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-08T23:28:32Z", - "last_commit": "2026-10-09T02:57:30Z" + "last_commit": "2026-10-09T15:59:56Z" }, { "login": "rossaai", @@ -440,14 +466,14 @@ "login": "JordyUSA", "people_entry": false, "github_name": null, - "prs": 2, - "commits": 3, - "lines_added": 4158, - "lines_deleted": 283, + "prs": 6, + "commits": 9, + "lines_added": 4948, + "lines_deleted": 405, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-09T01:35:57Z", - "last_commit": "2026-10-09T02:53:28Z" + "last_commit": "2026-10-10T15:06:45Z" }, { "login": "jerrykimo", @@ -466,14 +492,27 @@ "login": "swillunderscore", "people_entry": true, "github_name": null, - "prs": 1, - "commits": 3, - "lines_added": 1008, - "lines_deleted": 13, + "prs": 2, + "commits": 6, + "lines_added": 2407, + "lines_deleted": 63, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-09T01:58:37Z", - "last_commit": "2026-10-09T02:19:21Z" + "last_commit": "2026-10-10T00:20:25Z" + }, + { + "login": "jnohclee-rgb", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 19, + "lines_deleted": 1, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T02:14:06Z", + "last_commit": "2026-10-09T02:14:06Z" }, { "login": "IppSec", @@ -492,14 +531,14 @@ "login": "sanyamsingh001", "people_entry": true, "github_name": null, - "prs": 2, - "commits": 2, - "lines_added": 323, - "lines_deleted": 25, + "prs": 5, + "commits": 5, + "lines_added": 1169, + "lines_deleted": 93, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-09T04:06:37Z", - "last_commit": "2026-10-09T05:44:39Z" + "last_commit": "2026-10-09T23:00:34Z" }, { "login": "ItzBloxy", @@ -513,6 +552,435 @@ "binary_deleted": 0, "first_commit": "2026-10-09T09:27:48Z", "last_commit": "2026-10-09T10:17:08Z" + }, + { + "login": "dmatviichuk", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 464, + "lines_deleted": 6, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T12:18:42Z", + "last_commit": "2026-10-09T12:18:42Z" + }, + { + "login": "David-T-Campos", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 7, + "lines_added": 61, + "lines_deleted": 8, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T15:25:06Z", + "last_commit": "2026-10-09T15:27:55Z" + }, + { + "login": "lloyd-nich", + "people_entry": false, + "github_name": null, + "prs": 0, + "commits": 3, + "lines_added": 1477, + "lines_deleted": 121, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T15:58:49Z", + "last_commit": "2026-10-10T07:02:57Z" + }, + { + "login": "haranishi", + "people_entry": false, + "github_name": null, + "prs": 4, + "commits": 4, + "lines_added": 3700, + "lines_deleted": 92, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T17:14:19Z", + "last_commit": "2026-10-10T08:34:16Z" + }, + { + "login": "vbasky", + "people_entry": false, + "github_name": null, + "prs": 10, + "commits": 11, + "lines_added": 1314, + "lines_deleted": 215, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T17:37:44Z", + "last_commit": "2026-10-10T16:41:59Z" + }, + { + "login": "ossini", + "people_entry": true, + "github_name": "Oskar Sobania", + "prs": 2, + "commits": 12, + "lines_added": 1381, + "lines_deleted": 149, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T18:43:22Z", + "last_commit": "2026-10-10T17:17:47Z" + }, + { + "login": "liangforstudy", + "people_entry": true, + "github_name": null, + "prs": 3, + "commits": 3, + "lines_added": 575, + "lines_deleted": 20, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T21:22:05Z", + "last_commit": "2026-10-10T09:40:58Z" + }, + { + "login": "ReReddo", + "people_entry": true, + "github_name": null, + "prs": 12, + "commits": 14, + "lines_added": 1918, + "lines_deleted": 126, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T21:30:29Z", + "last_commit": "2026-10-10T19:45:20Z" + }, + { + "login": "MasakatsuFunaki", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 261, + "lines_deleted": 13, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T21:49:24Z", + "last_commit": "2026-10-09T21:49:24Z" + }, + { + "login": "maccagnit", + "people_entry": false, + "github_name": null, + "prs": 4, + "commits": 4, + "lines_added": 221, + "lines_deleted": 30, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T23:00:05Z", + "last_commit": "2026-10-10T15:22:56Z" + }, + { + "login": "digitaldecaycreations-lgtm", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 11, + "lines_deleted": 0, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T23:20:58Z", + "last_commit": "2026-10-09T23:20:58Z" + }, + { + "login": "dylanmtaylor", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 37, + "lines_deleted": 42, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T00:04:17Z", + "last_commit": "2026-10-10T00:04:17Z" + }, + { + "login": "sawizzle", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 2, + "lines_added": 23, + "lines_deleted": 6, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T00:06:15Z", + "last_commit": "2026-10-10T04:50:49Z" + }, + { + "login": "luniiya", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 697, + "lines_deleted": 6, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T00:14:55Z", + "last_commit": "2026-10-10T00:14:55Z" + }, + { + "login": "chuygarcia92", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 38, + "lines_deleted": 33, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T02:20:17Z", + "last_commit": "2026-10-10T02:20:17Z" + }, + { + "login": "Hathor875", + "people_entry": true, + "github_name": "Krzysztof Cieślik", + "prs": 1, + "commits": 1, + "lines_added": 109, + "lines_deleted": 4, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T02:21:06Z", + "last_commit": "2026-10-10T02:21:06Z" + }, + { + "login": "Guilhermeb-Ferrarezi", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 2, + "lines_added": 161, + "lines_deleted": 1, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T02:43:20Z", + "last_commit": "2026-10-10T02:48:16Z" + }, + { + "login": "mitchmckinstry", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 3, + "lines_added": 399, + "lines_deleted": 11, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T03:14:19Z", + "last_commit": "2026-10-10T03:25:58Z" + }, + { + "login": "gyroninja", + "people_entry": false, + "github_name": null, + "prs": 2, + "commits": 2, + "lines_added": 178, + "lines_deleted": 12, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T04:32:42Z", + "last_commit": "2026-10-10T10:14:18Z" + }, + { + "login": "hadmark", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 4, + "lines_added": 3608, + "lines_deleted": 4, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T06:08:41Z", + "last_commit": "2026-10-10T06:32:49Z" + }, + { + "login": "SilentYeti", + "people_entry": false, + "github_name": null, + "prs": 2, + "commits": 3, + "lines_added": 1562, + "lines_deleted": 72, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T06:17:11Z", + "last_commit": "2026-10-10T09:17:16Z" + }, + { + "login": "adurr21", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 287, + "lines_deleted": 68, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T07:03:03Z", + "last_commit": "2026-10-10T07:03:03Z" + }, + { + "login": "maxm11", + "people_entry": true, + "github_name": null, + "prs": 3, + "commits": 6, + "lines_added": 487, + "lines_deleted": 38, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T07:19:38Z", + "last_commit": "2026-10-10T14:18:27Z" + }, + { + "login": "Ni-zav", + "people_entry": false, + "github_name": null, + "prs": 10, + "commits": 13, + "lines_added": 498, + "lines_deleted": 65, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T08:47:24Z", + "last_commit": "2026-10-10T21:55:59Z" + }, + { + "login": "ivandrozdov1505-coder", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 404, + "lines_deleted": 5, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T10:40:22Z", + "last_commit": "2026-10-10T10:40:22Z" + }, + { + "login": "KattKnight", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 3, + "lines_added": 335, + "lines_deleted": 9, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T10:56:07Z", + "last_commit": "2026-10-10T13:00:21Z" + }, + { + "login": "keller-lukas", + "people_entry": true, + "github_name": null, + "prs": 1, + "commits": 2, + "lines_added": 480, + "lines_deleted": 15, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T11:06:02Z", + "last_commit": "2026-10-10T11:06:02Z" + }, + { + "login": "4papuce", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 39, + "lines_deleted": 0, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T12:43:28Z", + "last_commit": "2026-10-10T12:43:28Z" + }, + { + "login": "vangel76", + "people_entry": true, + "github_name": null, + "prs": 49, + "commits": 53, + "lines_added": 11727, + "lines_deleted": 752, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T13:47:11Z", + "last_commit": "2026-10-10T22:34:28Z" + }, + { + "login": "abdelkabirouadoukou", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 30, + "lines_deleted": 8, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T15:54:00Z", + "last_commit": "2026-10-10T15:54:00Z" + }, + { + "login": "impostazioni", + "people_entry": false, + "github_name": null, + "prs": 3, + "commits": 3, + "lines_added": 596, + "lines_deleted": 34, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T17:29:09Z", + "last_commit": "2026-10-10T20:52:28Z" + }, + { + "login": "alex-brazh", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 159, + "lines_deleted": 4, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T19:55:52Z", + "last_commit": "2026-10-10T19:55:52Z" + }, + { + "login": "LloydNicholson", + "people_entry": false, + "github_name": null, + "prs": 3, + "commits": 0, + "lines_added": 0, + "lines_deleted": 0, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": null, + "last_commit": null } ], "models": [ @@ -521,27 +989,27 @@ "company": "Anthropic", "model": "Claude Opus", "version": "5.5", - "commits": 474, - "lines_added": 346706, - "lines_deleted": 19057 + "commits": 602, + "lines_added": 374868, + "lines_deleted": 20972 }, { "id": "anthropic/claude-sonnet/5.5", "company": "Anthropic", "model": "Claude Sonnet", "version": "5.5", - "commits": 18, - "lines_added": 13935, - "lines_deleted": 854 + "commits": 25, + "lines_added": 18034, + "lines_deleted": 957 }, { "id": "openai/codex", "company": "OpenAI", "model": "Codex", "version": "", - "commits": 11, - "lines_added": 2057, - "lines_deleted": 361 + "commits": 16, + "lines_added": 3654, + "lines_deleted": 503 }, { "id": "anthropic/claude-fable/5.1", @@ -557,9 +1025,9 @@ "company": "Anthropic", "model": "Claude", "version": "", - "commits": 1, - "lines_added": 235, - "lines_deleted": 22 + "commits": 2, + "lines_added": 496, + "lines_deleted": 35 }, { "id": "anthropic/claude-opus/4.8", diff --git a/docs/releasing.md b/docs/releasing.md index 631e2ee9..58c28374 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -50,6 +50,7 @@ signing jobs are refused, and the draft-release job, which needs them, is skippe | Windows on ARM64 | `filmcraft--windows-arm64.msi`, `filmcraft--windows-arm64-portable.zip` | `windows-latest` (cross-compiled) | | Linux x86_64 | `filmcraft--linux-x86_64.{AppImage,deb,rpm,tar.gz}` | `ubuntu-22.04` | | Linux aarch64 | `filmcraft--linux-aarch64.{AppImage,deb,rpm,tar.gz}` | `ubuntu-22.04-arm` | +| Linux riscv64 | `filmcraft--linux-riscv64.tar.gz` | `ubuntu-24.04`, cross-compiled (glibc 2.39+; CLI smoke-tested under QEMU) | | AppImage updates | `filmcraft--linux-{x86_64,aarch64}.AppImage.zsync` | with the AppImage | | Flatpak | `filmcraft--linux-x86_64.flatpak`, `filmcraft--linux-aarch64.flatpak` | `ubuntu-24.04`, `ubuntu-24.04-arm` (repackaged Linux tarball) | | FreeBSD 14 x86_64 | `filmcraft--freebsd-x86_64.tar.gz` | FreeBSD 14.3 VM on `ubuntu-latest` | diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index b2d00bc3..5001bf71 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Build and package FilmCraft for Linux ( is x86_64 or aarch64): +# Build and package FilmCraft for Linux ( is x86_64 or aarch64, or riscv64 when cross-compiling): # # $DIST/filmcraft--linux-.AppImage any distro with glibc >= the build host's # $DIST/filmcraft--linux-.AppImage.zsync delta updates (needs zsyncmake) @@ -13,6 +13,10 @@ # (downloaded into $CARGO_TARGET_DIR if missing). Build on an old distro (CI: Ubuntu 22.04, # glibc 2.35) so the binaries run on newer ones. Optional: desktop-file-validate, appstreamcli, # zsyncmake (the zsync package) for the AppImage's .zsync. +# +# Cross-compiling (CI's riscv64 job, tar only): set CROSS_ARCH (riscv64), CROSS_TARGET (the Rust +# target), CROSS_COMPILE (binutils prefix, for strip) and optionally EMULATOR (qemu-riscv64) to +# smoke-test the CLI. set -euo pipefail # shellcheck source=../env.sh . "$(dirname "${BASH_SOURCE[0]}")/../env.sh" @@ -25,13 +29,14 @@ while [ $# -gt 0 ]; do case "$1" in --skip-build) SKIP_BUILD=1; shift ;; --formats) FORMATS="$2"; shift 2 ;; - -h | --help) sed -n '2,16p' "$0"; exit 0 ;; + -h | --help) sed -n '2,21p' "$0"; exit 0 ;; *) echo "unknown argument: $1" >&2; exit 2 ;; esac done -ARCH="$(uname -m)" +ARCH="${CROSS_ARCH:-$(uname -m)}" case "$ARCH" in + riscv64) DEB_ARCH=riscv64 ;; x86_64) DEB_ARCH=amd64 ;; aarch64 | arm64) ARCH=aarch64; DEB_ARCH=arm64 ;; *) echo "unsupported architecture $ARCH" >&2; exit 2 ;; @@ -42,9 +47,13 @@ BASENAME="filmcraft-$VERSION-linux-$ARCH" echo "==> FilmCraft $VERSION for Linux $ARCH ($FORMATS)" if [ "$SKIP_BUILD" = 0 ]; then - (cd "$ROOT" && cargo build --release --locked -p filmcraft -p filmcraft-cli) + 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) + fi fi -BIN="$CARGO_TARGET_DIR/release" +BIN="$CARGO_TARGET_DIR/${CROSS_TARGET:+$CROSS_TARGET/}release" WORK="$CARGO_TARGET_DIR/linux-package" STAGE="$WORK/root" rm -rf "$WORK" @@ -52,7 +61,7 @@ rm -rf "$WORK" # ---- stage an FHS tree (shared by every format) ------------------------------------------------- install -Dm755 "$BIN/filmcraft" "$STAGE/usr/bin/filmcraft" install -Dm755 "$BIN/filmcraft-cli" "$STAGE/usr/bin/filmcraft-cli" -strip "$STAGE/usr/bin/filmcraft" "$STAGE/usr/bin/filmcraft-cli" 2>/dev/null || true +"${CROSS_COMPILE:-}strip" "$STAGE/usr/bin/filmcraft" "$STAGE/usr/bin/filmcraft-cli" 2>/dev/null || true install -Dm644 "$HERE/$APP_ID.desktop" "$STAGE/usr/share/applications/$APP_ID.desktop" install -Dm644 "$HERE/$APP_ID.mime.xml" "$STAGE/usr/share/mime/packages/$APP_ID.xml" mkdir -p "$STAGE/usr/share/metainfo" @@ -136,6 +145,10 @@ if has appimage; then fi fi -"$STAGE/usr/bin/filmcraft-cli" --version +if [ -z "${CROSS_TARGET:-}" ]; then + "$STAGE/usr/bin/filmcraft-cli" --version +elif [ -n "${EMULATOR:-}" ]; then + "$EMULATOR" "$STAGE/usr/bin/filmcraft-cli" --version +fi echo "==> done" ls -lh "$DIST"