From 0bc4795083d72123353eb8cc9dbdfa8af8fc3339 Mon Sep 17 00:00:00 2001 From: KitsuneSemCalda Date: Fri, 9 Oct 2026 22:50:29 -0300 Subject: [PATCH 001/260] Add OmaStore manifest --- omastore.toml | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 omastore.toml diff --git a/omastore.toml b/omastore.toml new file mode 100644 index 000000000..8d1ee53ae --- /dev/null +++ b/omastore.toml @@ -0,0 +1,11 @@ +kind = "app" +categories = ["AudioVideo", "Video", "AudioVideoEditing"] +icon = "assets/app-icon/hicolor/256x256/apps/ai.storyteller.filmcraft.png" + +[linux.x86_64] +asset = "filmcraft-{version}-linux-x86_64.tar.gz" +exec = "filmcraft-{version}-linux-x86_64/usr/bin/filmcraft" + +[linux.aarch64] +asset = "filmcraft-{version}-linux-aarch64.tar.gz" +exec = "filmcraft-{version}-linux-aarch64/usr/bin/filmcraft" From c33cf33fe0ba5faa31e15fea0397bd3a7422bce3 Mon Sep 17 00:00:00 2001 From: Ikki Nishihara Date: Sat, 10 Oct 2026 17:29:44 +0900 Subject: [PATCH 002/260] 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 000000000..bd6dd6d6b --- /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 707c6a435..d8896b3df 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 c89b143d8..815939a64 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 003/260] 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 58c300b56..5ba3ed683 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 cdcb62db0..7e9854fe1 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 004/260] 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 c04416c6a..ef4aeaf77 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 005/260] 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 c03c4ecc4..9fbb8d596 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 7a54afaea..ce19774a8 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 bb504f32f..cf381abdc 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 006/260] #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 a31da3a7e..f7de9e3d2 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 cef204d9d..2efb5b537 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 3f1f5047d..751b18fdf 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 000000000..e602486e7 --- /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 9344059cd..4278a1b83 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 6334f4517..3b7e025c4 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 af8af356c..1288895fd 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 007/260] 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 b6fcfe86d..ae73e578f 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 70cdeb65a..e6efa1692 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 d8487251c..ad5f5d2aa 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 008/260] 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 bac68febb..3a61efbc0 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 be104b30c..657ee4ff9 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 39d53f770..65bc1fac5 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 009/260] 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 db2c92d79..bcc1b42af 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 0d4b6f28e..e698d2bfb 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 010/260] 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 70cdeb65a..8cdeb61f5 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 2757ebba4..799e7608f 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 a2ef4ab41..e0511fab3 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 6c918b4c4..e2ce210aa 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 dad3b0e45..48f1d5e51 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 f08414847..63014c751 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 4af317817..c715e927f 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 a63b6a0ec..4344cb52f 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 2962f6704..94b1b928a 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 aa1568317..9cc83dac5 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 14a5c02d0..d0c31cb17 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 07a10488e..78ac70d1a 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 4838ce5ff..b47fa32e7 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 011/260] 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 c09c8cc08..e20a91d10 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 000000000..b93ebf66b --- /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 012/260] 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 6634320c0..2cb4838ed 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 8ea07cd2b..a6be28413 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 013/260] 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 b92b6054d..5fd2b90c0 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 6c918b4c4..2821b57b3 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 c479a0171..03b88a74d 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 014/260] 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 3e94fa45b..ce7bd609e 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 7a32f5498..e8e5f9283 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 c63f8295b..65aa26b58 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 9243483fd..e2deb5b86 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 3f4b845e3..71c581510 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 000000000..b8daace57 --- /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 000000000..23c128ac7 --- /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 5cac050e4..581af9a37 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 015/260] 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 bd80f88bf..f3865ea02 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 26df0e699..13b482270 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 000000000..b951f1f40 --- /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 000000000..44c9b409e --- /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 7037404ab..95c8a7efa 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 2f020835f..10db5c873 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 d5437e972..f3cf83c34 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 016/260] 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 d5437e972..527c3184e 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 09b040694..e859f539d 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 7a54afaea..fbfe6922e 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 017/260] 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 000000000..01f9f4212 --- /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 adf417ca7..416fbf9d7 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 d769ea4be..21512ccc5 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 000000000..893f40d3f --- /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 018/260] 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 8d15cde2a..a0546d037 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 aa86c493d..2c96adc21 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 476c4159b..672239442 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 019/260] #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 2f5a8382b..2c7dc21ed 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 ae258cb0f..dc0a3e20f 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 d59978393..c1b3c69dd 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 8ea07cd2b..849d63864 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 020/260] 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 1839bb29f..4d8821721 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 57877b4c6..150bc67cb 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 c8ed56e01..ea3f6d687 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 0b3a9f8a6..16ac177ff 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 eea55b32a..fe6f94be9 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 000000000..10ff7eed7 --- /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 000000000..6ca2dc2b9 --- /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 857f42a56..786a1aa72 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 9dff12d38..7ba3df1d9 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 000000000..86beeb73c --- /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 021/260] 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 bd80f88bf..9aef2f7cf 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 17ff888c9..5a08ba972 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 2f020835f..865d3758f 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 241f36f6d..0d8faa169 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 699a95d49..1c723fea4 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 000000000..019f4d174 --- /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 fb88bec66..05ce228b4 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 9dff12d38..dbce3b26b 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 022/260] 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 50788355b..46dc3db23 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 023/260] 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 8d15cde2a..0d932ca57 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 aa86c493d..4bf45a801 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 476c4159b..a4e113714 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 d5437e972..7b7bfc8fe 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 024/260] 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 b951f1f40..ddef6f118 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 025/260] 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 7a54afaea..381829bcd 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 bb504f32f..a56b89287 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 026/260] #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 0b3a9f8a6..bc07eef07 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 ce377f64d..c3df07e18 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 eea55b32a..b2779f641 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 027/260] 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 50788355b..d461a335e 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 028/260] 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 a23e7530c..a0e2d181a 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 029/260] 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 62f4f547c..dcb23c019 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 000000000..34b8e18b6 --- /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 030/260] 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 50788355b..4af53d71c 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 031/260] 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 dcb23c019..2c1b4c590 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 34b8e18b6..ae937d79f 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 032/260] 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 2c1b4c590..d2e217e26 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 ae937d79f..349dbb07e 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 033/260] #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 846592968..735f5b281 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 42d3c79e9..b094a54f1 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 857f42a56..727e80f73 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 09b040694..3493ee93a 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 bb504f32f..c148b58c7 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 034/260] #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 162602581..6ebc1158f 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 1eeb87b20..3a595010b 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 aacea877e..74ca44e14 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 630a8ceae..c97cd5066 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 9cf58cf66..841a8275d 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 035/260] #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 d94a44573..6e6f3fb0a 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 cf1cfff55..ee774c861 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 8e9668d6d..96a12a786 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 e3ccfda26..721e35cfa 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 857f42a56..e5a9b1b8b 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 641463d52..6d980e6c8 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 9dff12d38..82b25306e 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 eed8fd28f..38d2af0ec 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 5d78e93e6..ef68c0aab 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 674769d6f..dfc960aad 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 41395d861..fd2d7b8dd 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 036/260] #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 2757ebba4..cf514b496 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 8e8fc7ef2..a0a083cd0 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 b494226bf..d2410ae61 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 037/260] 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 a56b89287..53d30b2e9 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 038/260] #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 188823bc8..8c993ca7d 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 3a490e2c4..7caf5eb98 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 b23b130e5..4f4428875 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 bfb391dd2..5c2b5f28b 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 039/260] #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 0b3a9f8a6..8cf771a3b 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 257357120..b003e48dd 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 d59978393..70e25656d 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 8ea07cd2b..13542a8f7 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 040/260] #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 84acdf5e7..bd37f6bb0 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 d5d5e6254..b5a94ab08 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 041/260] 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 5807d1ba8..40dec64cb 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 db84a1f6c..adf9d35ed 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 31f5edfc0..ecb02d9d0 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 042/260] #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 75f711447..bd1c14e83 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 db8065b37..7a2ab0e11 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 043/260] 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 0b3a9f8a6..dd169aed7 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 cb2df9587..f1aeb5987 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 1abd4e66d..d83c31aad 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 7833c51b2..70d9dae17 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 35f4e464a..29c8f60ae 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 6a00196d8..9d462e28f 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 044/260] 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 0b3a9f8a6..20a21a643 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 ca36d9cce..8d028fe7a 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 9481641f6..cdcc5ef34 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 045/260] 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 ca36d9cce..c8f213a2b 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 972220b3a..7b26dc0c6 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 6b5ff1b1e..d65a06304 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 9521cba44..8c60e0158 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 046/260] #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 b6fcfe86d..8e90933ab 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 0b3a9f8a6..928c25d18 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 08f899ecb..980ef5374 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 8dbf44199..f9ec82d09 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 731067f98..9e46602c2 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 1eeb87b20..e1f3aebb8 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 630a8ceae..87986f547 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 047/260] #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 2f020835f..7b0c11dde 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 857f42a56..fed5a8fac 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 41395d861..4dd25b64c 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 048/260] 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 1abd4e66d..200fdee13 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 6dd339933..956478570 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 bdca8e52d..9c7c966ad 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 6a00196d8..d6bcb40d4 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 049/260] 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 4e9130dde..5edf47673 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 050/260] #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 0b3a9f8a6..ec5385be8 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 2757ebba4..a1f3f2318 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 312d0301e..05196d72c 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 24233e7a3..ecd8ede23 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 306aef2fd..78503d002 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 3aa37d056..4323de772 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 8e8fc7ef2..8b761693e 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 656ddd9f5..ae8e9fd6c 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 b66ab5521..c93f5ebc8 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 051/260] #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 1eeb87b20..e3313a14a 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 052/260] 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 11f4bc2d8..ddd157089 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 aacea877e..4425775c3 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 053/260] 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 d59978393..5304eaec9 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 000000000..697fc81fd --- /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 054/260] 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 b37fc4d01..dfa64771c 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 055/260] #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 0b3a9f8a6..52ad73c43 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 9852734d7..f2b50dd8a 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 d59978393..474214f76 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 036606320..c5db5b8d6 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 056/260] #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 de3370d29..cef30cd83 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 80246ef0d..c36dc8ca4 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 057/260] #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 857f42a56..86fcb8eb0 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 9dff12d38..d47a525ec 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 036606320..52ece3995 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 058/260] 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 87ef1e566..b4d4758b7 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 1109890e1..faff9dcea 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 059/260] #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 2aeb47bfe..6ec25fae3 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 6b63bf4ae..4a0df3d2b 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 060/260] 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 ad0123951..cc5d1e37d 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 061/260] 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 cc5d1e37d..27fa5c079 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 062/260] 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 27fa5c079..21fb3353d 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 063/260] #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 78958249e..e9d9dc3bc 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 79fa2237b..74540670f 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 631678d84..648f217b0 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 064/260] #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 2aeb47bfe..ac959ad78 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 065/260] 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 09aead4f5..3f38c565f 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 11f4bc2d8..8476a32b4 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 d59978393..4a99b725d 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 000000000..66095e652 --- /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 066/260] #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 162602581..a68bc34ac 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 08f899ecb..1c3284f3d 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 8dbf44199..1afb0d57a 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 731067f98..e6d56c7ea 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 d59978393..9aa3d98cd 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 aacea877e..93133616d 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 ef23d50f4..af20a815f 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 9cf58cf66..f13e0e7ce 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 067/260] 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 2f5a8382b..3bd1b370e 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 068/260] #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 0b3a9f8a6..390cc2366 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 000000000..66f1d2d95 --- /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 eea55b32a..0d48e26c5 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 dda7a4d14..951a873c8 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 069/260] #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 d59978393..ca2276061 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 000000000..930f291dc --- /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 070/260] #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 9dff12d38..2a0c8eb5f 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 494f9d08f..7cdb7c21f 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 7ae955461..c20283035 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 d60ff85bb..3610c9ea3 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 071/260] 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 50788355b..976aaa4ce 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 072/260] #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 08f899ecb..c996891d0 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 8dbf44199..b23fb61eb 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 731067f98..7f11f499d 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 073/260] #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 08f899ecb..681fe92de 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 8dbf44199..be1f78a23 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 731067f98..44e1de7df 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 074/260] #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 f13e0e7ce..c75902c17 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 075/260] #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 515a10d42..b5a4d5da2 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 972220b3a..aa1b59389 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 076/260] 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 d59978393..59342feda 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 077/260] 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 5c43e5251..77c9bf848 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 013c6efc8..d8236e8bd 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 4408779df..e5b83ba66 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 000000000..bd6822d55 --- /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 078/260] 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 88864eaf3..1154f99ff 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 312d0301e..48b1ca2e0 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 8e8fc7ef2..0acb2f936 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 b494226bf..80a53c1b9 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 ee926cb5d..b9afd53ea 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 08f899ecb..192dcc033 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 8dbf44199..ae497299e 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 731067f98..44919051c 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 b66ab5521..0dc3fbb12 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 079/260] 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 7b5ee2c1a..235d8423a 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 080/260] 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 9774c0069..ecee03914 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 c1c214751..14100d146 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 081/260] 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 41395d861..e09609cfa 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 3d19aaaff..eca13d504 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 082/260] 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 de1e02182..7ec2cf731 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 aa81758ca..3c14de2bd 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 a39dc82ea..bff06198b 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 e82e2c3cf..67f336064 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 a9a770f6c..e13acb062 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 140d60ccc..ad7c4ad55 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 000000000..cd65b1344 --- /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 083/260] 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 2f020835f..68271518b 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 241f36f6d..50a3cf3af 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 084/260] 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 f616a9f8c..0683cb87a 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 9779b45c2..2ecdacc20 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 fc1acfebe..e74820ab7 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 e22211dbd..e5d94acf5 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 2e303cbb5..094877d4f 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 9481641f6..a64d130f1 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 000000000..83416bf28 --- /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 c38791fce..245334b08 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 000000000..b19262c84 --- /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 000000000..3532ae7b5 --- /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 000000000..d39f4b9fd --- /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 000000000..a4a18386f --- /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 000000000..3484e30a2 --- /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 000000000..29b2f7067 --- /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 000000000..611bb8278 --- /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 085/260] #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 bac68febb..aaa954202 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 086/260] #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 62f4f547c..0ef36d35d 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 087/260] #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 cda0f0c20..df0c534b9 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 5f7c02f4a..e364402ac 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 088/260] #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 bac68febb..af531c50c 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 b051d6cbf..2d54c4923 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 2736806e5..8552cc640 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 089/260] 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 09aead4f5..349353843 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 538798521..b7553926b 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 433d491b2..fd04667d1 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 090/260] #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 2f5a8382b..554e9e5c7 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 0b3a9f8a6..47cc2f181 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 d8487251c..55563816f 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 b23a9809d..60e9b7b43 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 091/260] 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 d59978393..81e324f60 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 b7560f321..a86834950 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 092/260] #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 d59978393..7fc8f8b5f 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 16dd5f712..9e311f50d 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 0a16547f8..460b7929a 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 093/260] #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 cef204d9d..f8902394b 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 094/260] #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 0247105fa..959a0fa0d 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 a20e94756..f4eeb3ba0 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 0b432ea0c..572d9627d 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 095/260] #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 9dff12d38..ba8f1f18b 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 09b040694..2a18137a1 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 7a54afaea..af7911fae 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 bb504f32f..1b978cf29 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 096/260] 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 e6161e368..9098c5a3b 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 4ffba906a..a74489bde 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 80a8a0d4a..47decaf28 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 097/260] 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 9098c5a3b..fd6588db5 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 a74489bde..5062918ea 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 b5775263a..242fc6e44 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 c97cd5066..61164c7b6 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 098/260] 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 d9e14c568..205fb5c28 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 099/260] 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 21fb3353d..da49f3b10 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 faff9dcea..1987ba328 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 e602486e7..e02c905d9 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 33e2ee85f..df6c80f4f 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 29c8f60ae..309e6d729 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 956478570..db22ed10c 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 be95de1f0..3f45ce9da 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 d41c22c4d..e504e3416 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 697fc81fd..aa1ef4c94 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 a6be28413..bac77555f 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 100/260] #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 45da2e219..6246e7a31 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 357e995a4..8be530abe 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 238649afd..70af3c747 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 e8a988568..aca499f9f 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 05acf45b7..d2242b260 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 08f899ecb..0b03b9883 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 8dbf44199..ed58e821b 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 1e9c995ea..fc7b9fd50 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 6fa303f4d..13312b311 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 731067f98..e39c7f50a 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 bcb386fbb..0e7b20d14 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 000000000..740db1f46 --- /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 e395f4a8a..d548d6cb9 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 a64d130f1..f9e005f7c 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 101/260] #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 33e2ee85f..a53dc12c3 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 e8a988568..b8ff0759f 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 bcb386fbb..5ba2abf85 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 46d800234..76cafdbee 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 3fac337e5..77ff7f1c4 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 102/260] #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 76f4f8c28..df3e7d939 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 bcb386fbb..95c9442e2 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 17a5eef96..84b863973 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 956e7f211..1ea047c84 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 103/260] #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 33e2ee85f..d49361f05 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 a0027913d..dc6c57953 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 9e164ce2c..d275f9b3e 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 17a5eef96..2b1e8fef2 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 104/260] 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 40dec64cb..5807d1ba8 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 adf9d35ed..db84a1f6c 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 ecb02d9d0..31f5edfc0 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 105/260] 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 1987ba328..6c98a7ff7 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 106/260] 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 df6c80f4f..c1f2f56de 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 f2b50dd8a..9852734d7 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 17a5eef96..e270db5f8 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 930ae086e..52ece3995 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 107/260] 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 68dc628b1..27b7f514d 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 7caf5eb98..3a490e2c4 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 4f4428875..b23b130e5 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 5c2b5f28b..bfb391dd2 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 108/260] 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 e270db5f8..9afd8825e 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 aa1ef4c94..000000000 --- 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 109/260] #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 2f5a8382b..200fe63b8 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 ae258cb0f..187bded0e 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 110/260] #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 2f5a8382b..1b978cf42 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 ae258cb0f..f8a5cb8d3 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 111/260] #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 12702ba6b..3e2a00038 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 ad5f5d2aa..57ddf72fd 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 112/260] #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 3cd86c35d..cd4084815 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 0faa82e1b..4c5f4ab03 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 104961e35..2d5b8b536 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 d02c16667..b60493718 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 28b86c30a..2a287a88a 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 5c1b08fdb..9f5be7285 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 113/260] #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 05acf45b7..ef680f5df 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 3f45ce9da..4aae9a529 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 3d54f8737..d94a5e419 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 e504e3416..30b2831fe 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 0faa82e1b..6ec07e120 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 674769d6f..55ee80319 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 bcb386fbb..4351e4fc4 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 e395f4a8a..836a8f94a 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 114/260] 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 306aef2fd..a2c4db220 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 656ddd9f5..ef7b182a1 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 f986711c6..92b333cde 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 115/260] 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 b97a5acff..f86f51a84 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 116/260] #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 86b21b7d9..fa8eb9de3 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 f82aa78bd..ac849710c 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 117/260] 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 3060810a8..7cf5f47b3 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 b7553926b..cf392822d 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 fd04667d1..e19dcbbd1 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 118/260] #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 c1f2f56de..d56a03b34 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 fe6f94be9..01166df8c 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 cdbcf35f7..5c1de07fe 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 3f45ce9da..2af7e2a24 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 3d54f8737..d32893095 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 e504e3416..e161fd526 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 bcb386fbb..66d0d4754 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 d94867f39..a57d0483a 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 119/260] #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 d5618b99f..31645e875 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 cdbcf35f7..84790a524 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 9e17453a1..7f434c70a 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 120/260] 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 d65a06304..bfc94de79 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 121/260] 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 b23b130e5..c1395e430 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 aef90e15c..012424d77 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 122/260] 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 e12c56526..d79efe38f 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 6f2eb007b..ab00abc27 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 e80c2f972..6f176b814 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 28b75008a..55c8b9c13 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 000000000..01840f43d --- /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 28b86c30a..fc8f62a87 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 baadac2cf81f57b3b980cd260e6c1b48b6869cdc Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sat, 10 Oct 2026 14:59:52 -0600 Subject: [PATCH 123/260] UI.9: integrate shared docking and preserve workspace behavior --- Cargo.lock | 10 + Cargo.toml | 2 + ROADMAP.md | 3 + apps/filmcraft/src/args.rs | 24 +- apps/filmcraft/src/main.rs | 4 +- crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/README.md | 25 ++ crates/ui-egui/src/automation.rs | 29 +- crates/ui-egui/src/control.rs | 52 ++- crates/ui-egui/src/dock.rs | 331 +++++++++----- crates/ui-egui/src/dock_shared.rs | 113 +++++ crates/ui-egui/src/dock_ui.rs | 394 +++++++++++++++++ crates/ui-egui/src/i18n/es.tsv | 6 + crates/ui-egui/src/i18n/zh-cn.tsv | 3 + crates/ui-egui/src/lib.rs | 139 +++--- crates/ui-egui/src/menus.rs | 7 + crates/ui-egui/src/panels/keyboard.rs | 8 +- crates/ui-egui/src/panels/mod.rs | 106 +++-- crates/ui-egui/src/panels/monitor.rs | 169 +++++-- crates/ui-egui/src/panels/timeline.rs | 7 + crates/ui-egui/src/panels/tools.rs | 13 +- crates/ui-egui/src/panels/workspaces.rs | 9 +- crates/ui-egui/src/state.rs | 3 + crates/ui-egui/tests/monitor_geometry.rs | 185 ++++++++ crates/ui-egui/tests/project_panel_ui.rs | 19 +- crates/ui-egui/tests/shared_docking_ui.rs | 515 ++++++++++++++++++++++ crates/ui-egui/tests/timecode_ui.rs | 34 ++ crates/ui-egui/tests/timeline_drop_ui.rs | 114 +++++ crates/ui-egui/tests/ui_control_input.rs | 277 ++++++++++++ crates/ui-egui/tests/ui_dock_resize.rs | 95 ++++ crates/ui-egui/tests/ui_keyboard_focus.rs | 107 +++++ docs/control-protocol.md | 43 +- xtask/src/main.rs | 2 +- 33 files changed, 2548 insertions(+), 301 deletions(-) create mode 100644 crates/ui-egui/src/dock_shared.rs create mode 100644 crates/ui-egui/src/dock_ui.rs create mode 100644 crates/ui-egui/tests/monitor_geometry.rs create mode 100644 crates/ui-egui/tests/shared_docking_ui.rs create mode 100644 crates/ui-egui/tests/ui_control_input.rs create mode 100644 crates/ui-egui/tests/ui_dock_resize.rs create mode 100644 crates/ui-egui/tests/ui_keyboard_focus.rs diff --git a/Cargo.lock b/Cargo.lock index d94a44573..66008b52b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1011,6 +1011,15 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-ui" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675" +dependencies = [ + "egui", + "serde", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -2262,6 +2271,7 @@ name = "filmcraft-ui-egui" version = "0.5.0" dependencies = [ "cpu-time", + "craft-ui", "eframe", "egui", "egui_kittest", diff --git a/Cargo.toml b/Cargo.toml index 1d306cffa..cfe2ae9b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,6 +67,8 @@ proptest = "1" roxmltree = "0.21" # ui +# Shared frontend components; keep this dependency above the engine boundary. +craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } egui = "0.36" eframe = { version = "0.36", default-features = false } egui_kittest = "0.36" diff --git a/ROADMAP.md b/ROADMAP.md index e22211dbd..b604e069e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,8 @@ # FilmCraft Roadmap +- UI.6: Compact Source and Program monitors separate timecodes from view settings and retain Source drag choices. Transport controls use the shared toolbar sizing helper, with overflow actions in an operable More menu and a scrolling fallback at tiny widths. +- UI.3: Panel tabs, edge splits, tab reordering and floating groups use the shared docking model and drop resolver. Floating geometry is saved with named workspaces while legacy layout JSON remains readable. Dock dividers use bounded shared geometry and keyboard-accessible resize handles. Tools and dock tabs expose actual widget focus, paint focus outlines, and support keyboard traversal/activation. Control input reaches focused widgets in background windows; optional input isolation supports unattended validation. Existing workspace trees and saved layout formats are retained. + Progress toward parity with Adobe Premiere Pro, with estimates. Updated as milestones land. **Last updated:** 2026-10-05 · **Feature checklist:** ~87% · **Ready for real work:** ~50–60% · **Code:** 35 crates, 1605+ tests diff --git a/apps/filmcraft/src/args.rs b/apps/filmcraft/src/args.rs index 6460a9684..db2080121 100644 --- a/apps/filmcraft/src/args.rs +++ b/apps/filmcraft/src/args.rs @@ -7,6 +7,8 @@ Usage: filmcraft [options] [project.fcproj | media files...] Options: --control start the localhost JSON-lines control server (also: FILMCRAFT_CONTROL_PORT) + --control-input-only accept widget input only from the control server + (requires --control or FILMCRAFT_CONTROL_PORT) --demo open the demo project --empty start with an empty project --recover recover the newest unsaved changes without asking @@ -29,6 +31,7 @@ pub enum Cli { #[derive(Debug, PartialEq)] pub struct Launch { pub control_port: Option, + pub control_input_only: bool, /// A project and / or media files to open. pub files: Vec, pub demo: bool, @@ -46,7 +49,7 @@ pub struct Launch { /// a script waiting for it. That holds for the whole line, so `--version --bogus` is an error /// too, and for a port from the environment exactly as for one from `--control`. pub fn parse(env_control_port: Option, args: impl IntoIterator) -> Result { - let mut l = Launch { control_port: None, files: Vec::new(), demo: true, startup_flag: false, recover: None, data_dir: None }; + let mut l = Launch { control_port: None, control_input_only: false, files: Vec::new(), demo: true, startup_flag: false, recover: None, data_dir: None }; let (mut help, mut version) = (false, false); let mut args = args.into_iter(); while let Some(a) = args.next() { @@ -58,6 +61,7 @@ pub fn parse(env_control_port: Option, args: impl IntoIterator (l.demo, l.startup_flag) = (true, true), + "--control-input-only" => l.control_input_only = true, "--empty" => (l.demo, l.startup_flag) = (false, true), "--recover" => l.recover = Some(true), "--no-recover" => l.recover = Some(false), @@ -81,6 +85,9 @@ pub fn parse(env_control_port: Option, args: impl IntoIterator eframe::Result { let logger = logging::install(); // lossy, so a value that is not Unicode is reported like any other bad port instead of ignored let env_port = std::env::var_os("FILMCRAFT_CONTROL_PORT").map(|p| p.to_string_lossy().into_owned()); - let Launch { control_port, files, demo, startup_flag, recover, data_dir } = match args::parse(env_port, std::env::args().skip(1)) { + let Launch { control_port, control_input_only, files, demo, startup_flag, recover, data_dir } = match args::parse(env_port, std::env::args().skip(1)) { Ok(Cli::Run(l)) => l, Ok(Cli::Help) => { tell(args::USAGE, false); @@ -267,7 +267,7 @@ fn main() -> eframe::Result { } if let Some(port) = control_port { let rx = control_server::start(port, cc.egui_ctx.clone()); - app = app.with_control(rx); + app = if control_input_only { app.with_control_input_only(rx) } else { app.with_control(rx) }; } Ok(Box::new(app)) }), diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 4a99b17b2..950c22acd 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -10,6 +10,7 @@ rust-version.workspace = true workspace = true [dependencies] +craft-ui = { workspace = true } filmcraft-time = { workspace = true } filmcraft-geom = { workspace = true } filmcraft-color = { workspace = true } diff --git a/crates/ui-egui/README.md b/crates/ui-egui/README.md index 699a95d49..0e9583e35 100644 --- a/crates/ui-egui/README.md +++ b/crates/ui-egui/README.md @@ -57,3 +57,28 @@ range validation and undo; the UI owns the drag gesture. See `source_drag_ui` an Source range handles use transient gesture previews and the engine's integer-frame `source_monitor::adjust_range`; release dispatches one mark edit. Monitor command routing keeps Source navigation, markers and marked-range playback separate from Program. + +## Pane layout controls + +Dock transactions and pointer targets use `craft-ui::docking`; bounded split geometry and +keyboard-accessible resize handles use `craft-ui::layout`. FilmCraft adapts its existing saved +dock tree and panel identities to the shared model. Panel tabs or their header grip can move +between tab groups, split at an edge, reorder, or float when released with Cmd/Ctrl. Floating +panels move by their empty header and resize from the bottom-right corner. Timeline sequence +tabs retain their sequence-opening, closing and horizontal reorder behavior; the header grip +moves the Timeline panel. Workspace saves retain floating groups alongside the legacy layout +JSON, and workspaces without floating data still load. Automation IDs and workspace commands +remain the integration boundary. + +Tools and dock tabs expose their actual egui widget IDs, paint keyboard focus, and support +Tab/Shift-Tab traversal and Space/Enter activation. Control-injected keys can reach focused +widgets in a background window. The optional `--control-input-only` native launch mode isolates +automation from native widget/menu input; `ui.inspect` reports native and effective focus +separately. Focus and input-ownership regressions cover document/playhead preservation. + +Compact monitor layouts separate the current and duration timecodes from zoom, resolution and +settings. Source video/audio drag choices retain their existing placement behavior. Transport +buttons use `craft-ui::layout::fit_toolbar`; commands beyond the available width remain available +through More, including their selected state and Source/Program routing. A scrolling fallback +retains all commands if even More cannot fit. `monitor_geometry` checks the painted readouts, +control bounds, overflow actions and Source drag payloads across monitor widths and themes. diff --git a/crates/ui-egui/src/automation.rs b/crates/ui-egui/src/automation.rs index 9b9ae3bb9..850fcb8dd 100644 --- a/crates/ui-egui/src/automation.rs +++ b/crates/ui-egui/src/automation.rs @@ -10,6 +10,15 @@ pub struct Element { pub id: String, pub label: String, pub rect: [f32; 4], + /// Present only when this entry represents a real egui widget, rather than a click alias. + #[serde(rename = "widgetId", skip_serializing_if = "Option::is_none")] + pub widget_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub focusable: Option, + #[serde(skip)] + pub(crate) response_id: Option, } #[derive(Default)] @@ -21,7 +30,25 @@ pub struct Registry { impl Registry { pub fn add(&mut self, id: &str, rect: Rect, label: &str) { - self.elements.push(Element { id: id.to_string(), label: label.to_string(), rect: [rect.min.x, rect.min.y, rect.width(), rect.height()] }); + self.elements.push(Element { + id: id.to_string(), + label: label.to_string(), + rect: [rect.min.x, rect.min.y, rect.width(), rect.height()], + widget_id: None, + enabled: None, + focusable: None, + response_id: None, + }); + } + /// Observe the widget without changing its accessibility role or input behavior. + pub fn add_response(&mut self, id: &str, response: &egui::Response, label: &str) { + self.add(id, response.rect, label); + if let Some(element) = self.elements.last_mut() { + element.widget_id = Some(format!("{:016x}", response.id.value())); + element.enabled = Some(response.enabled()); + element.focusable = Some(response.sense.is_focusable()); + element.response_id = Some(response.id); + } } pub fn begin_frame(&mut self) { self.previous = std::mem::take(&mut self.elements); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 162602581..7b4bdd6d1 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -288,7 +288,9 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) if req.method == "ui.panel.show" { app.show_panel(panel); } else { - app.ui.dock.close(panel); + if let Err(error) = app.apply_dock(craft_ui::docking::Action::Close { panel }) { + return err(&error); + } } ok(Value::Null) } @@ -299,6 +301,7 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) Err(e) => return err(e), }; let m = modifiers(p); + app.synthetic.push(egui::Event::ModifiersChanged(m)); app.synthetic.push(egui::Event::PointerMoved(pos)); if req.method == "ui.click" { let button = match s("button") { @@ -312,6 +315,7 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: false, modifiers: m }); } } + app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); Outcome::AfterInput } "ui.drag" => { @@ -323,6 +327,7 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) }; let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(12).max(2); let m = modifiers(p); + app.synthetic.push(egui::Event::ModifiersChanged(m)); app.synthetic.push(egui::Event::PointerMoved(a)); app.synthetic.push(egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: m }); for i in 1..=steps { @@ -330,6 +335,7 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) app.synthetic.push(egui::Event::PointerMoved(a + (b - a) * f)); } app.synthetic.push(egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: m }); + app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); Outcome::AfterInput } "ui.scroll" => { @@ -339,6 +345,7 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) }; let dx = p.get("dx").and_then(Value::as_f64).unwrap_or(0.0) as f32; let dy = p.get("dy").and_then(Value::as_f64).unwrap_or(0.0) as f32; + app.synthetic.push(egui::Event::ModifiersChanged(modifiers(p))); app.synthetic.push(egui::Event::PointerMoved(pos)); app.synthetic.push(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, @@ -352,18 +359,22 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) pressed: false, modifiers: Default::default(), }); + app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); Outcome::AfterInput } "ui.key" => { let Some(name) = s("key") else { return err("missing `key`") }; let Some((m, key)) = crate::menus::parse_shortcut(name) else { return err(format!("unknown key `{name}`")) }; let m = as_pressed(m | modifiers(p)); + app.synthetic.push(egui::Event::ModifiersChanged(m)); app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: m }); app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: m }); + app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); Outcome::AfterInput } "ui.type" => { let Some(text) = s("text") else { return err("missing `text`") }; + app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); app.synthetic.push(egui::Event::Text(text.to_string())); app.synthetic.push(egui::Event::Key { key: egui::Key::F35, physical_key: None, pressed: false, repeat: false, modifiers: Default::default() }); Outcome::AfterInput @@ -404,9 +415,30 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) ok(Value::Null) } "ui.focus" => { - // Explicit request: activate the app and take keyboard focus. - ctx.send_viewport_cmd(egui::ViewportCommand::Focus); - ok(Value::Null) + if p.is_null() || p.as_object().is_some_and(|object| object.is_empty()) { + // Existing explicit request: activate the app and take OS keyboard focus. + ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + return ok(Value::Null); + } + let Some(object) = p.as_object().filter(|object| object.len() == 1 && object.contains_key("id")) else { + return err("ui.focus expects {} or {id: string}"); + }; + let Some(id) = object.get("id").and_then(Value::as_str) else { return err("ui.focus needs a string `id`") }; + let Some(element) = app.auto.find(id) else { return err(format!("no element `{id}` (see ui.elements)")) }; + let Some(widget_id) = element.response_id else { return err(format!("element `{id}` has no focusable widget")) }; + let Some(response) = ctx.read_response(widget_id) else { return err(format!("widget `{id}` is not present")) }; + if !response.enabled() + || !response.sense.is_focusable() + || !response.interact_rect.is_finite() + || !response.interact_rect.is_positive() + || !response.interact_rect.intersects(ctx.content_rect()) + { + return err(format!("widget `{id}` is not enabled, visible and focusable")); + } + // A widget target never activates the native window. The next ui.inspect reports + // actual focus ownership separately from this request acknowledgement. + response.request_focus(); + ok(json!({"focusRequested": id, "widgetId": format!("{:016x}", widget_id.value())})) } "app.quit" => { ctx.send_viewport_cmd(egui::ViewportCommand::Close); @@ -435,10 +467,22 @@ fn merge(v: &mut Value, patch: &Value) { pub fn inspect(app: &FilmcraftApp, ctx: &egui::Context) -> Value { let size = ctx.content_rect().size(); + let keyboard_focus = ctx.memory(|memory| memory.focused()).map(|id| { + let elements: Vec<_> = app.auto.previous.iter().filter(|element| element.response_id == Some(id)).collect(); + json!({ + "widgetId": format!("{:016x}", id.value()), + "elementIds": elements.iter().map(|element| element.id.as_str()).collect::>(), + "label": elements.first().map(|element| element.label.as_str()), + "active": ctx.read_response(id).is_some_and(|response| response.has_focus()), + }) + }); json!({ "window": [size.x, size.y], "pixelsPerPoint": ctx.pixels_per_point(), "fps": app.fps, + "keyboardFocus": keyboard_focus, + "input": {"mode": if app.control_input_only { "controlOnly" } else { "interactive" }, + "nativeWindowFocused": app.native_window_focused, "effectiveWindowFocused": ctx.input(|input| input.focused)}, "ui": app.ui, "sourcePlayback": {"playing": app.source_playback.clock.playing, "audioClock": app.source_playback.clock.audio_clock, "playhead": app.session.state.source_playhead.0}, "playback": {"playing": app.playback.playing, "speed": app.playback.speed, "loop": app.playback.looping, "audioClock": app.playback.audio_clock, "dropped": app.playback.meter.counts().1, "shown": app.playback.meter.counts().0, "preroll": app.playback.preroll.is_some()}, diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 05acf45b7..218bca0c1 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -5,6 +5,8 @@ use egui::{Align2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use serde::{Deserialize, Serialize}; +use craft_ui::layout::{ResizeHandle, ResizeHandleStyle, SplitAxis, SplitSize as SharedSplitSize, split_rect}; + use crate::icons::{self, Icon}; use crate::theme::Tokens; @@ -137,6 +139,16 @@ pub enum SplitSize { FixedB(f32), } +impl SplitSize { + fn shared(self) -> SharedSplitSize { + match self { + Self::Ratio(value) => SharedSplitSize::Ratio(value), + Self::FixedA(value) => SharedSplitSize::FixedFirst(value), + Self::FixedB(value) => SharedSplitSize::FixedSecond(value), + } + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub enum DockNode { /// `vertical`: children stacked top/bottom; else side by side. @@ -183,11 +195,24 @@ pub struct WorkspacePrefs { pub struct SavedWorkspace { pub name: String, pub layout: serde_json::Value, + /// Kept raw for the same per-workspace failure isolation as the dock tree. + #[serde(default, skip_serializing_if = "serde_json::Value::is_null")] + pub floating: serde_json::Value, } impl WorkspacePrefs { pub fn load(path: &std::path::Path) -> Self { - std::fs::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default() + use std::io::Read as _; + // Preferences are untrusted; bound reading before JSON can allocate from it. + const MAX_BYTES: u64 = 4 * 1024 * 1024; + let Some(bytes) = std::fs::File::open(path).ok().and_then(|file| { + let mut bytes = Vec::new(); + file.take(MAX_BYTES + 1).read_to_end(&mut bytes).ok()?; + (bytes.len() as u64 <= MAX_BYTES).then_some(bytes) + }) else { + return Self::default(); + }; + serde_json::from_slice(&bytes).unwrap_or_default() } pub fn save(&self, path: &std::path::Path) -> std::io::Result<()> { if let Some(d) = path.parent() { @@ -228,7 +253,55 @@ pub fn find(prefs: &WorkspacePrefs, query: &str) -> Option { /// The saved layout of a workspace: the user's saved version, else the built-in default. pub fn saved_layout(prefs: &WorkspacePrefs, name: &str) -> DockNode { - prefs.saved.iter().find(|s| s.name == name).and_then(|s| serde_json::from_value(s.layout.clone()).ok()).unwrap_or_else(|| workspace(name)) + saved_docking(prefs, name).root +} + +pub fn saved_docking(prefs: &WorkspacePrefs, name: &str) -> Layout { + let saved = prefs.saved.iter().find(|saved| saved.name == name).and_then(|saved| { + let mut layout = Layout { + root: serde_json::from_value(saved.layout.clone()).ok()?, + floating: if saved.floating.is_null() { vec![] } else { serde_json::from_value(saved.floating.clone()).ok()? }, + }; + if layout.floating.is_empty() { + migrate_legacy_effects(&mut layout.root); + } + layout.validate().ok().map(|()| layout) + }); + saved.unwrap_or_else(|| Layout { root: workspace(name), floating: vec![] }) +} + +/// Older Effects workspaces stored the same Effect Controls panel twice: selected beside +/// Source, and an inactive duplicate beside Essential Graphics. Remove only that known +/// duplicate shape; unrelated malformed or ambiguous saved layouts still fail validation. +fn migrate_legacy_effects(root: &mut DockNode) { + let Ok(shared) = crate::dock_shared::encode(root, &[]) else { return }; + if shared.panels().iter().filter(|panel| ***panel == PanelKind::EffectControls).count() != 2 { + return; + } + fn selected_source(node: &DockNode) -> bool { + match node { + DockNode::Tabs { panels, active } => { + panels.contains(&PanelKind::Source) && panels.contains(&PanelKind::AudioClipMixer) && panels.get(*active) == Some(&PanelKind::EffectControls) + } + DockNode::Split { a, b, .. } => selected_source(a) || selected_source(b), + } + } + fn remove_inactive(node: &mut DockNode) { + match node { + DockNode::Tabs { panels, active } if panels.as_slice() == [PanelKind::EffectControls, PanelKind::EssentialGraphics] && *active == 1 => { + panels.remove(0); + *active = 0; + } + DockNode::Split { a, b, .. } => { + remove_inactive(a); + remove_inactive(b); + } + DockNode::Tabs { .. } => {} + } + } + if selected_source(root) { + remove_inactive(root); + } } /// The default layout of a named workspace. @@ -262,7 +335,7 @@ pub fn workspace(name: &str) -> DockNode { hsplit(Ratio(0.5), tabs(&[Source, EffectControls, AudioClipMixer], 1), tabs(&[Program], 0)), hsplit(Ratio(0.3), tabs(&[Project, MediaBrowser, Info, Markers, History], 0), bottom_editing()), ), - vsplit(Ratio(0.5), tabs(&[Effects], 0), tabs(&[EffectControls, EssentialGraphics], 1)), + vsplit(Ratio(0.5), tabs(&[Effects], 0), tabs(&[EssentialGraphics], 0)), ), "Audio" => hsplit( FixedB(320.0), @@ -327,17 +400,7 @@ impl DockNode { } /// Make `p` the active tab of its group. Returns false if not present. pub fn activate(&mut self, p: PanelKind) -> bool { - match self { - DockNode::Split { a, b, .. } => a.activate(p) || b.activate(p), - DockNode::Tabs { panels, active } => { - if let Some(i) = panels.iter().position(|x| *x == p) { - *active = i; - true - } else { - false - } - } - } + crate::dock_shared::apply_tree(self, craft_ui::docking::Action::Activate { panel: p }) } pub fn is_visible(&self, p: PanelKind) -> bool { match self { @@ -347,19 +410,7 @@ impl DockNode { } /// Close a panel (remove its tab; empty groups collapse their split). pub fn close(&mut self, p: PanelKind) { - if let DockNode::Split { a, b, .. } = self { - a.close(p); - b.close(p); - let empty = |n: &DockNode| matches!(n, DockNode::Tabs { panels, .. } if panels.is_empty()); - if empty(a) { - *self = (**b).clone(); - } else if empty(b) { - *self = (**a).clone(); - } - } else if let DockNode::Tabs { panels, active } = self { - panels.retain(|x| *x != p); - *active = (*active).min(panels.len().saturating_sub(1)); - } + crate::dock_shared::apply_tree(self, craft_ui::docking::Action::Close { panel: p }); } /// Put the Timeline panel back where the workspaces keep it (left of the audio meters) after /// it was closed, so opening a sequence always has somewhere to show it. @@ -383,27 +434,40 @@ impl DockNode { } /// Add a panel as a tab next to `near` (or into the first group). pub fn open_near(&mut self, p: PanelKind, near: PanelKind) { - if self.contains(p) { - self.activate(p); + if crate::dock_shared::encode(self, &[]).is_err() { return; } - fn add(n: &mut DockNode, p: PanelKind, near: PanelKind) -> bool { - match n { - DockNode::Split { a, b, .. } => add(a, p, near) || add(b, p, near), - DockNode::Tabs { panels, active } => { - if panels.contains(&near) { - panels.push(p); - *active = panels.len() - 1; - true - } else { - false - } - } - } - } - if !add(self, p, near) && !add(self, p, PanelKind::Project) { - add(self, p, PanelKind::Program); - } + let anchor = [near, PanelKind::Project, PanelKind::Program].into_iter().find(|panel| self.contains(*panel)); + crate::dock_shared::apply_tree(self, craft_ui::docking::Action::Open { panel: p, anchor }); + } +} + +/// A floating group retains the same panel identities as docked tabs. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Floating { + pub panels: Vec, + pub active: usize, + /// [x, y, width, height] in logical points. + pub rect: [f32; 4], +} + +/// App persistence shape; all mutations pass through the shared docking model. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Layout { + pub root: DockNode, + #[serde(default)] + pub floating: Vec, +} + +impl Layout { + pub fn apply(&mut self, action: craft_ui::docking::Action) -> Result<(), String> { + crate::dock_shared::apply(self, action) + } + pub fn validate(&self) -> Result<(), String> { + crate::dock_shared::encode(&self.root, &self.floating)?.validate().map_err(|error| error.to_string()) + } + pub fn contains(&self, panel: PanelKind) -> bool { + self.root.contains(panel) || self.floating.iter().any(|group| group.panels.contains(&panel)) } } @@ -420,6 +484,7 @@ pub struct Group { #[derive(Debug, Clone, PartialEq)] pub enum DockAction { Activate(PanelKind), + BeginDrag(PanelKind), Focus(PanelKind), Close(PanelKind), PanelMenu(PanelKind, egui::Pos2), @@ -430,22 +495,40 @@ pub enum DockAction { MoveSequence(u64, usize), } -/// Size of a split's first child in `avail` points: the requested size, keeping both children at -/// least 20 points when there is room (and never more than `avail`). Must not panic for any size: -/// a tiny window (or browser canvas) gives splits less than 40 points. -fn split_first(size: SplitSize, avail: f32) -> f32 { - let want = match size { - SplitSize::Ratio(r) => avail * r, - SplitSize::FixedA(px) => px, - SplitSize::FixedB(px) => avail - px, - }; - let lo = 20.0_f32.min(avail * 0.5); - let hi = (avail - 20.0).max(lo); - want.clamp(lo, hi) -} - /// Lay out the tree into group rects (with `gap` gutters) and handle gutter dragging. pub fn layout(ui: &mut egui::Ui, node: &mut DockNode, rect: Rect, t: &Tokens, path: &str, out: &mut Vec, reg: &mut crate::automation::Registry) { + let mut actions = Vec::new(); + layout_with_actions(ui, node, rect, t, path, out, reg, &mut actions); + for action in actions { + crate::dock_shared::apply_tree(node, action); + } +} + +/// Lay out chrome while returning mutations for the application's UI command route. +pub fn layout_with_actions( + ui: &mut egui::Ui, + node: &mut DockNode, + rect: Rect, + t: &Tokens, + path: &str, + out: &mut Vec, + reg: &mut crate::automation::Registry, + actions: &mut Vec>, +) { + layout_inner(ui, node, rect, t, path, &[], out, reg, actions); +} + +fn layout_inner( + ui: &mut egui::Ui, + node: &mut DockNode, + rect: Rect, + t: &Tokens, + path: &str, + tree_path: &[bool], + out: &mut Vec, + reg: &mut crate::automation::Registry, + actions: &mut Vec>, +) { match node { DockNode::Tabs { panels, active } => { let tab_h = if panels.len() == 1 && panels[0].compact() { 8.0 } else { t.tab_h }; @@ -453,45 +536,24 @@ pub fn layout(ui: &mut egui::Ui, node: &mut DockNode, rect: Rect, t: &Tokens, pa out.push(Group { path: path.to_string(), rect, content, panels: panels.clone(), active: *active }); } DockNode::Split { vertical, size, a, b } => { - let g = t.gap; - let total = if *vertical { rect.height() } else { rect.width() }; - let avail = (total - g).max(0.0); - let first = split_first(*size, avail); - let (ra, gutter, rb) = if *vertical { - ( - Rect::from_min_max(rect.min, pos2(rect.max.x, rect.min.y + first)), - Rect::from_min_max(pos2(rect.min.x, rect.min.y + first), pos2(rect.max.x, rect.min.y + first + g)), - Rect::from_min_max(pos2(rect.min.x, rect.min.y + first + g), rect.max), - ) - } else { - ( - Rect::from_min_max(rect.min, pos2(rect.min.x + first, rect.max.y)), - Rect::from_min_max(pos2(rect.min.x + first, rect.min.y), pos2(rect.min.x + first + g, rect.max.y)), - Rect::from_min_max(pos2(rect.min.x + first + g, rect.min.y), rect.max), - ) - }; - // gutter drag (a slightly larger hit area than the visible gap) - let hit = gutter.expand2(if *vertical { vec2(0.0, 3.0) } else { vec2(3.0, 0.0) }); + let axis = if *vertical { SplitAxis::Vertical } else { SplitAxis::Horizontal }; + let Some(split) = split_rect(rect, axis, size.shared(), t.gap, 20.0) else { return }; let id = egui::Id::new(("dock-gutter", path.to_string())); - let resp = ui.interact(hit, id, Sense::drag()); - reg.add(&format!("dock.gutter.{path}"), hit, "gutter"); - if resp.hovered() || resp.dragged() { - ui.ctx().set_cursor_icon(if *vertical { egui::CursorIcon::ResizeVertical } else { egui::CursorIcon::ResizeHorizontal }); + let handle_style = ResizeHandleStyle { hovered: t.focus.gamma_multiply(0.4), dragged: t.focus.gamma_multiply(0.9), focused: t.focus }; + let label = if *vertical { tl!("Height:") } else { tl!("Width:") }; + let response = ResizeHandle::new(id, axis, split.divider, label, handle_style).value(split.first_size(), 0.0..=split.available()).show(ui); + reg.add_response(&format!("dock.gutter.{path}"), &response.response, "gutter"); + if response.delta != 0.0 + && let Some(resized) = split.resized(size.shared(), response.delta, 40.0) + { + actions.push(craft_ui::docking::Action::ResizeSplit { path: tree_path.to_vec(), size: resized }); } - if resp.dragged() { - let d = if *vertical { resp.drag_delta().y } else { resp.drag_delta().x }; - let nf = (first + d).clamp(40.0, (avail - 40.0).max(40.0)); - *size = match *size { - SplitSize::Ratio(_) => SplitSize::Ratio(nf / avail.max(1.0)), - SplitSize::FixedA(_) => SplitSize::FixedA(nf), - SplitSize::FixedB(_) => SplitSize::FixedB(avail - nf), - }; - } - if resp.dragged() || resp.hovered() { - ui.painter().rect_filled(gutter, 0.0, t.focus.gamma_multiply(if resp.dragged() { 0.9 } else { 0.4 })); - } - layout(ui, a, ra, t, &format!("{path}a"), out, reg); - layout(ui, b, rb, t, &format!("{path}b"), out, reg); + let mut first_path = tree_path.to_vec(); + first_path.push(false); + let mut second_path = tree_path.to_vec(); + second_path.push(true); + layout_inner(ui, a, split.first, t, &format!("{path}a"), &first_path, out, reg, actions); + layout_inner(ui, b, split.second, t, &format!("{path}b"), &second_path, out, reg, actions); } } } @@ -512,11 +574,39 @@ pub fn draw_group_chrome( t: &Tokens, reg: &mut crate::automation::Registry, ) -> Vec { + draw_group_chrome_details(ui, g, focused, seqs, t, reg).actions +} + +#[derive(Default)] +pub struct Chrome { + pub actions: Vec, + pub tabs: Vec<(PanelKind, Rect)>, +} + +pub fn draw_group_chrome_details( + ui: &mut egui::Ui, + g: &Group, + focused: PanelKind, + seqs: &SeqTabs, + t: &Tokens, + reg: &mut crate::automation::Registry, +) -> Chrome { let mut actions = Vec::new(); + let mut tab_rects: Vec<(PanelKind, Rect)> = Vec::new(); let painter = ui.painter().clone(); painter.rect_filled(g.rect, t.radius, t.panel_bg); let active_panel = g.panels.get(g.active).copied(); let compact = g.panels.len() == 1 && g.panels[0].compact(); + // A narrow header grip moves the entire panel, including Timeline panels whose tabs + // separately reorder open sequences. + if let Some(panel) = active_panel { + let grip = Rect::from_min_size(g.rect.min, vec2(if compact { g.rect.width() } else { 10.0 }, if compact { 8.0 } else { t.tab_h })); + let response = ui.interact(grip, egui::Id::new(("panel-drag", g.path.clone())), Sense::drag()); + reg.add_response(&format!("panel.drag.{}", panel.id()), &response, "Move panel"); + if response.drag_started() { + actions.push(DockAction::BeginDrag(panel)); + } + } if compact { // grip dots let c = pos2(g.rect.center().x, g.rect.min.y + 4.0); @@ -578,10 +668,12 @@ pub fn draw_group_chrome( if let Some(r) = more { let resp = ui.interact(r, egui::Id::new(("tab-overflow", g.path.clone())), Sense::click()); let name = if g.panels.contains(&PanelKind::Timeline) { "timeline.tabs.more".to_string() } else { format!("panel.tabs.more.{}", g.path) }; - reg.add(&name, r, "more tabs"); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, resp.enabled(), "more tabs")); + reg.add_response(&name, &resp, "more tabs"); let c = if resp.hovered() { t.tab_text_active } else { t.tab_text }; icons::paint(&painter, r.shrink(4.0).translate(vec2(-2.0, 0.0)), Icon::ChevronRight, c); icons::paint(&painter, r.shrink(4.0).translate(vec2(2.0, 0.0)), Icon::ChevronRight, c); + paint_widget_focus(&painter, &resp, t); if resp.clicked() { let open = ui.ctx().data(|d| d.get_temp::(list_id)).unwrap_or(false); ui.ctx().data_mut(|d| d.insert_temp(list_id, !open)); @@ -602,8 +694,22 @@ pub fn draw_group_chrome( // a sequence tab keeps its identity when the tabs are reordered under the pointer let resp = match tab.seq { Some(id) => ui.interact(tab_rect, egui::Id::new(("tab-seq", g.path.clone(), id)), Sense::click_and_drag()), - None => ui.interact(tab_rect, egui::Id::new(("tab", g.path.clone(), i)), Sense::click()), + None => ui.interact(tab_rect, egui::Id::new(("tab", g.path.clone(), i)), Sense::click_and_drag()), }; + if tab.seq.is_none() && resp.drag_started() { + actions.push(DockAction::BeginDrag(*p)); + } + if let Some((_, rect)) = tab_rects.iter_mut().find(|(panel, _)| panel == p) { + *rect = rect.union(tab_rect); + } else { + tab_rects.push((*p, tab_rect)); + } + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, resp.enabled(), is_active, &tab.title)); + ui.ctx().accesskit_node_builder(resp.id, |node| { + node.set_role(egui::accesskit::Role::Tab); + node.set_selected(is_active); + }); + paint_widget_focus(&painter, &resp, t); if let Some(id) = tab.seq { seq_rects.push((id, tab_rect)); if resp.dragged() { @@ -611,10 +717,10 @@ pub fn draw_group_chrome( } } if let Some(id) = tab.seq { - reg.add(&format!("timeline.tab.{id}"), tab_rect, &tab.title); + reg.add_response(&format!("timeline.tab.{id}"), &resp, &tab.title); } if tab.seq.is_none() || is_active { - reg.add(&format!("panel.tab.{}", p.id()), tab_rect, p.title()); + reg.add_response(&format!("panel.tab.{}", p.id()), &resp, p.title()); } let label_x = tab_rect.min.x + 8.0 + close_w; let label_w = galley.size().x; @@ -625,10 +731,12 @@ pub fn draw_group_chrome( // × closes the sequence's tab (the sequence stays in the project) let cr = Rect::from_center_size(pos2(tab_rect.min.x + 10.0, text_y), vec2(8.0, 8.0)); let cresp = ui.interact(cr.expand(4.0), egui::Id::new(("tab-close", g.path.clone(), id)), Sense::click()); - reg.add(&format!("timeline.tab.{id}.close"), cr.expand(4.0), "close sequence"); + cresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, cresp.enabled(), "close sequence")); + reg.add_response(&format!("timeline.tab.{id}.close"), &cresp, "close sequence"); let cc = if cresp.hovered() { t.tab_text_active } else { t.tab_text }; painter.line_segment([cr.left_top(), cr.right_bottom()], Stroke::new(1.2, cc)); painter.line_segment([cr.right_top(), cr.left_bottom()], Stroke::new(1.2, cc)); + paint_widget_focus(&painter, &cresp, t); if cresp.clicked() { actions.push(DockAction::CloseSequence(id)); closed = true; @@ -637,12 +745,14 @@ pub fn draw_group_chrome( if is_active { let mr = Rect::from_center_size(pos2(label_x + label_w + 12.0, text_y), vec2(12.0, 10.0)); let mresp = ui.interact(mr.expand(3.0), egui::Id::new(("tab-menu", g.path.clone())), Sense::click()); - reg.add(&format!("panel.menu.{}", p.id()), mr, "panel menu"); + mresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, mresp.enabled(), "panel menu")); + reg.add_response(&format!("panel.menu.{}", p.id()), &mresp, "panel menu"); let mc = if mresp.hovered() { t.tab_text_active } else { t.tab_text }; icons::paint(&painter, Rect::from_center_size(mr.center(), vec2(16.0, 16.0)), Icon::Hamburger, mc); // 1 pt underline spanning label + ≡, 23 pt below the frame top let uy = strip.min.y + 23.0; painter.line_segment([pos2(label_x, uy), pos2(mr.max.x, uy)], Stroke::new(1.0, t.tab_text_active)); + paint_widget_focus(&painter, &mresp, t); if mresp.clicked() { actions.push(DockAction::PanelMenu(*p, mr.left_bottom())); } @@ -697,8 +807,8 @@ pub fn draw_group_chrome( let label = if tab.active { format!("✓ {}", tab.title) } else { tab.title.clone() }; let resp = ui.button(label); match tab.seq { - Some(id) => reg.add(&format!("timeline.tabs.list.{id}"), resp.rect, &tab.title), - None => reg.add(&format!("panel.tabs.list.{}", tab.panel.id()), resp.rect, &tab.title), + Some(id) => reg.add_response(&format!("timeline.tabs.list.{id}"), &resp, &tab.title), + None => reg.add_response(&format!("panel.tabs.list.{}", tab.panel.id()), &resp, &tab.title), } if resp.clicked() { picked = Some(tab); @@ -730,7 +840,13 @@ pub fn draw_group_chrome( { actions.push(DockAction::Focus(p)); } - actions + Chrome { actions, tabs: tab_rects } +} + +fn paint_widget_focus(painter: &egui::Painter, response: &egui::Response, tokens: &Tokens) { + if response.has_focus() { + painter.rect_stroke(response.rect.shrink(2.0), 2.0, Stroke::new(1.5, tokens.focus), StrokeKind::Inside); + } } /// Placeholder body for panels that are not implemented yet. @@ -744,6 +860,9 @@ mod tests { #[test] fn split_first_never_panics_on_tiny_areas() { + let split_first = |size: SplitSize, avail: f32| { + split_rect(Rect::from_min_size(egui::Pos2::ZERO, vec2(avail, 100.0)), SplitAxis::Horizontal, size.shared(), 0.0, 20.0).unwrap().first_size() + }; for avail in [0.0, 1.0, 15.0, 20.0, 35.0, 39.9, 40.0, 100.0, 2000.0] { for size in [ SplitSize::Ratio(0.0), diff --git a/crates/ui-egui/src/dock_shared.rs b/crates/ui-egui/src/dock_shared.rs new file mode 100644 index 000000000..d5bb28c20 --- /dev/null +++ b/crates/ui-egui/src/dock_shared.rs @@ -0,0 +1,113 @@ +//! Convert the persisted workspace schema to shared docking transactions. + +use craft_ui::docking::{Action, Floating as SharedFloating, Layout as SharedLayout, Node}; +use craft_ui::layout::{SplitAxis, SplitSize as SharedSplitSize}; + +use crate::dock::{DockNode, Floating, Layout, PanelKind, SplitSize}; + +const MAX_DEPTH: usize = 32; +const MAX_NODES: usize = 4096; +const MAX_PANELS: usize = 128; + +fn encode_node(node: &DockNode, depth: usize, count: &mut usize) -> Result, String> { + *count = count.saturating_add(1); + if depth > MAX_DEPTH || *count > MAX_NODES { + return Err("workspace dock tree exceeds supported limits".into()); + } + Ok(match node { + DockNode::Tabs { panels, active } => { + if panels.len() > MAX_PANELS { + return Err("too many workspace tabs".into()); + } + Node::Tabs { panels: panels.clone(), active: *active } + } + DockNode::Split { vertical, size, a, b } => Node::Split { + axis: if *vertical { SplitAxis::Vertical } else { SplitAxis::Horizontal }, + size: match *size { + SplitSize::Ratio(value) => SharedSplitSize::Ratio(value), + SplitSize::FixedA(value) => SharedSplitSize::FixedFirst(value), + SplitSize::FixedB(value) => SharedSplitSize::FixedSecond(value), + }, + first: Box::new(encode_node(a, depth.saturating_add(1), count)?), + second: Box::new(encode_node(b, depth.saturating_add(1), count)?), + }, + }) +} + +fn decode_node(node: Node) -> Result { + Ok(match node { + Node::Tabs { panels, active } => DockNode::Tabs { panels, active }, + Node::Stack { .. } => return Err("FilmCraft does not use stacked panels".into()), + Node::Split { axis, size, first, second } => DockNode::Split { + vertical: axis == SplitAxis::Vertical, + size: match size { + SharedSplitSize::Ratio(value) => SplitSize::Ratio(value), + SharedSplitSize::FixedFirst(value) => SplitSize::FixedA(value), + SharedSplitSize::FixedSecond(value) => SplitSize::FixedB(value), + }, + a: Box::new(decode_node(*first)?), + b: Box::new(decode_node(*second)?), + }, + }) +} + +pub(crate) fn encode(root: &DockNode, floating: &[Floating]) -> Result, String> { + if floating.len() > MAX_PANELS || floating.iter().any(|group| group.panels.len() > MAX_PANELS) { + return Err("too many floating workspace panels".into()); + } + let root = if matches!(root, DockNode::Tabs { panels, active: 0 } if panels.is_empty()) { None } else { Some(encode_node(root, 0, &mut 0)?) }; + Ok(SharedLayout { + root, + floating: floating.iter().map(|group| SharedFloating { panels: group.panels.clone(), active: group.active, rect: group.rect }).collect(), + }) +} + +pub(crate) fn decode(layout: SharedLayout) -> Result { + Ok(Layout { + root: match layout.root { + Some(root) => decode_node(root)?, + None => DockNode::Tabs { panels: vec![], active: 0 }, + }, + floating: layout.floating.into_iter().map(|group| Floating { panels: group.panels, active: group.active, rect: group.rect }).collect(), + }) +} + +pub(crate) fn apply(layout: &mut Layout, action: Action) -> Result<(), String> { + let mut shared = encode(&layout.root, &layout.floating)?; + shared.validate().map_err(|error| error.to_string())?; + check_last_docked(&shared, &action)?; + shared.apply(action).map_err(|error| error.to_string())?; + let proposed = decode(shared)?; + encode(&proposed.root, &proposed.floating)?; + *layout = proposed; + Ok(()) +} + +pub(crate) fn apply_tree(root: &mut DockNode, action: Action) -> bool { + let Ok(mut shared) = encode(root, &[]) else { return false }; + if shared.apply(action).is_err() { + return false; + } + let Ok(layout) = decode(shared) else { return false }; + if encode(&layout.root, &[]).is_err() { + return false; + } + *root = layout.root; + true +} + +/// Keep one docked destination available for dragging floating panels back into the workspace. +pub(crate) fn check_last_docked(layout: &SharedLayout, action: &Action) -> Result<(), String> { + let panel = match action { + Action::Float { panel, .. } => Some(panel), + Action::Move { panel, anchor, .. } if layout.floating.iter().any(|group| group.panels.contains(anchor)) => Some(panel), + _ => None, + }; + if let Some(panel) = panel { + let docked = SharedLayout { root: layout.root.clone(), floating: vec![] }; + if docked.contains(panel) && docked.panels().len() <= 1 { + return Err("the last docked panel must remain in the workspace".into()); + } + } + Ok(()) +} diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs new file mode 100644 index 000000000..d90888abf --- /dev/null +++ b/crates/ui-egui/src/dock_ui.rs @@ -0,0 +1,394 @@ +//! FilmCraft's panel chrome and document tabs over the shared docking transactions. + +use craft_ui::docking::{Action, DropTarget, Placement, Zone, resolve_drop}; +use egui::{Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; +use serde_json::{Value, json}; + +use crate::dock::{self, DockAction, DockNode, Group, PanelKind}; +use crate::{FilmcraftApp, panels}; + +/// Single source for rendering and hit testing: saved offscreen positions remain +/// reachable, and every panel has enough room for its chrome. +fn floating_rect(group: &dock::Floating, screen: Rect) -> Rect { + let rect = Rect::from_min_size(pos2(group.rect[0], group.rect[1]), vec2(group.rect[2].max(160.0), group.rect[3].max(100.0))); + rect.translate(vec2( + (screen.min.x - rect.min.x).max(0.0) + (screen.max.x - 40.0 - rect.min.x).min(0.0), + (screen.min.y - rect.min.y).max(0.0) + (screen.max.y - 30.0 - rect.min.y).min(0.0), + )) +} + +fn drag_id() -> egui::Id { + egui::Id::new("dock-panel-drag") +} + +pub(crate) fn route(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: &Value) -> Option> { + if id == "window.dockAction" { + return Some((|| { + let value = params.get("action").cloned().ok_or("missing docking action")?; + let action = serde_json::from_value(value).map_err(|error| format!("invalid docking action: {error}"))?; + app.apply_dock(action)?; + Ok(Value::Null) + })()); + } + if !matches!(id, "window.dockPanel" | "window.floatPanel" | "window.closePanel") { + return None; + } + Some((|| { + let panel = match params.get("panel") { + None => app.ui.focused, + Some(value) => value.as_str().and_then(PanelKind::from_name).ok_or("unknown panel")?, + }; + let action = match id { + "window.closePanel" => Action::Close { panel }, + "window.floatPanel" => { + let rect = match params.get("rect") { + None => default_float_rect(ctx.content_rect()), + Some(value) => serde_json::from_value::<[f32; 4]>(value.clone()).map_err(|_| "rect must contain four finite numbers")?, + }; + Action::Float { panel, rect } + } + _ => { + let anchor = params.get("anchor").and_then(Value::as_str).and_then(PanelKind::from_name).ok_or("a known anchor panel is required")?; + let zone_name = match params.get("zone") { + None => "center", + Some(value) => value.as_str().ok_or("zone must be a string")?, + }; + let zone = match zone_name.to_ascii_lowercase().as_str() { + "left" => Zone::Left, + "right" => Zone::Right, + "top" => Zone::Top, + "bottom" => Zone::Bottom, + "center" | "tab" => Zone::Center, + _ => return Err("unknown docking zone".into()), + }; + let placement = match params.get("before") { + Some(value) => Placement::Tab { before: Some(value.as_str().and_then(PanelKind::from_name).ok_or("unknown before panel")?) }, + None => Placement::Split(zone), + }; + Action::Move { panel, anchor, placement } + } + }; + app.apply_dock(action)?; + Ok(json!({"panel": panel.id()})) + })()) +} + +pub(crate) fn default_float_rect(screen: Rect) -> [f32; 4] { + let center = screen.center(); + [center.x - 210.0, center.y - 160.0, 420.0, 320.0] +} + +impl FilmcraftApp { + fn dock_command(&mut self, ctx: &egui::Context, action: Action) -> Result<(), String> { + let value = serde_json::to_value(action).map_err(|error| error.to_string())?; + crate::menus::invoke(self, ctx, "window.dockAction", json!({"action": value})).map(|_| ()) + } + + pub fn apply_dock(&mut self, action: Action) -> Result<(), String> { + let relocating = matches!(&action, Action::Move { .. } | Action::Float { .. }); + let reveal = match &action { + Action::Activate { panel } => Some(*panel), + _ => None, + }; + let mut shared = crate::dock_shared::encode(&self.ui.dock, &self.ui.floating)?; + shared.validate().map_err(|error| error.to_string())?; + crate::dock_shared::check_last_docked(&shared, &action)?; + shared.apply(action).map_err(|error| error.to_string())?; + let layout = crate::dock_shared::decode(shared)?; + crate::dock_shared::encode(&layout.root, &layout.floating)?; + self.ui.dock = layout.root; + self.ui.floating = layout.floating; + if let Some(panel) = reveal { + self.pending_panel_raise = Some(panel); + } + if relocating + || self.ui.keys.maximized.is_some_and(|panel| !self.ui.dock.contains(panel) && !self.ui.floating.iter().any(|group| group.panels.contains(&panel))) + { + self.ui.keys.maximized = None; + } + Ok(()) + } + + fn panel_body(&mut self, ui: &mut egui::Ui, panel: PanelKind, content: Rect) { + self.auto.add(&format!("panel.{}", panel.id()), content, panel.title()); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(content).id_salt(("panel", panel.id()))); + child.set_clip_rect(content.intersect(ui.clip_rect())); + panels::show(self, &mut child, panel, content); + } + + /// OS drag positions come from the host cursor hook, so egui pointer capture alone + /// cannot decide whether a Timeline is covered. Use the same geometry and layer + /// ordering as our renderer before any panel can dispatch an edit. + pub(crate) fn timeline_drop_target(&self, ctx: &egui::Context, pos: egui::Pos2) -> bool { + if !ctx.content_rect().contains(pos) { + return false; + } + if let Some(panel) = + self.ui.keys.maximized.filter(|panel| self.ui.dock.contains(*panel) || self.ui.floating.iter().any(|group| group.panels.contains(panel))) + { + return panel == PanelKind::Timeline; + } + for index in self.floating_order(ctx).into_iter().rev() { + if let Some(group) = self.ui.floating.get(index) + && floating_rect(group, ctx.content_rect()).contains(pos) + { + return group.panels.get(group.active) == Some(&PanelKind::Timeline); + } + } + true + } + + fn floating_order(&self, ctx: &egui::Context) -> Vec { + let layers = ctx.memory(|memory| memory.layer_ids().collect::>()); + let mut order: Vec<_> = (0..self.ui.floating.len()).collect(); + order.sort_by_key(|index| { + self.ui + .floating + .get(*index) + .and_then(|group| group.panels.first()) + .and_then(|panel| { + let id = egui::Id::new(("floating-panel", panel.id())); + layers.iter().position(|layer| layer.id == id) + }) + .unwrap_or(usize::MAX) + }); + order + } + + pub(crate) fn dock_area(&mut self, ui: &mut egui::Ui, body: Rect) { + let t = self.tokens; + let ctx = ui.ctx().clone(); + if let Some(panel) = self.pending_panel_raise.take() + && let Some(owner) = self.ui.floating.iter().find(|group| group.panels.contains(&panel)).and_then(|group| group.panels.first()) + { + ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, egui::Id::new(("floating-panel", owner.id())))); + } + let maximized = + self.ui.keys.maximized.filter(|panel| self.ui.dock.contains(*panel) || self.ui.floating.iter().any(|group| group.panels.contains(panel))); + let mut dock = match maximized { + Some(panel) => DockNode::Tabs { panels: vec![panel], active: 0 }, + None => std::mem::replace(&mut self.ui.dock, DockNode::Tabs { panels: vec![], active: 0 }), + }; + let mut groups = vec![]; + let mut layout_actions = Vec::new(); + dock::layout_with_actions(ui, &mut dock, body, &t, "", &mut groups, &mut self.auto, &mut layout_actions); + let seqs = dock::SeqTabs { + open: self + .session + .state + .open_sequences + .iter() + .filter_map(|id| { + self.session + .project + .item(*id) + .filter(|item| matches!(item.kind, filmcraft_project::ItemKind::Sequence(_))) + .map(|item| (id.0, item.name.clone())) + }) + .collect(), + active: self.session.state.active_sequence.map(|id| id.0), + }; + let mut actions = vec![]; + let mut targets = vec![]; + for group in &groups { + let chrome = dock::draw_group_chrome_details(ui, group, self.ui.focused, &seqs, &t, &mut self.auto); + if let Some(target) = target(group, chrome.tabs, true) { + targets.push(target); + } + actions.extend(chrome.actions); + } + if maximized.is_none() { + self.ui.dock = dock; + } + for group in &groups { + if let Some(panel) = group.panels.get(group.active).copied() { + self.panel_body(ui, panel, group.content); + } + } + let floating_order = self.floating_order(&ctx); + for index in floating_order.into_iter().filter(|_| maximized.is_none()) { + if let Some(target) = self.floating_group(&ctx, index, &seqs, &mut actions) { + targets.push(target); + } + } + for action in layout_actions { + let _ = self.dock_command(&ctx, action); + } + for action in actions { + match action { + DockAction::Activate(panel) => { + let _ = self.dock_command(&ctx, Action::Activate { panel }); + } + DockAction::Close(panel) => { + let _ = self.dock_command(&ctx, Action::Close { panel }); + } + DockAction::Focus(panel) => self.ui.focused = panel, + DockAction::BeginDrag(panel) => { + ctx.data_mut(|data| data.insert_temp(drag_id(), panel)); + } + DockAction::OpenSequence(id) => { + let _ = self.dock_command(&ctx, Action::Activate { panel: PanelKind::Timeline }); + if self.session.state.active_sequence.map(|item| item.0) != Some(id) + && let Err(error) = self.session.execute("sequence.open", json!({"item": id})) + { + self.ui.status = error.to_string(); + } + } + DockAction::CloseSequence(id) => { + if let Err(error) = self.session.execute("sequence.close", json!({"item": id})) { + self.ui.status = error.to_string(); + } + } + DockAction::MoveSequence(id, index) => { + if let Err(error) = self.session.execute("sequence.moveTab", json!({"item": id, "index": index})) { + self.ui.status = error.to_string(); + } + } + DockAction::PanelMenu(panel, pos) => { + let frame = ctx.cumulative_frame_nr(); + ctx.data_mut(|data| { + data.insert_temp(egui::Id::new("panel-menu"), (panel, pos)); + data.insert_temp(egui::Id::new("panel-menu-opened"), frame); + }); + } + } + } + self.panel_drag(&ctx, &targets); + panels::panel_menu_popup(self, ui); + } + + fn floating_group(&mut self, ctx: &egui::Context, index: usize, seqs: &dock::SeqTabs, actions: &mut Vec) -> Option> { + let floating = self.ui.floating.get(index)?.clone(); + let panel = *floating.panels.first()?; + let rect = floating_rect(&floating, ctx.content_rect()); + let t = self.tokens; + let id = egui::Id::new(("floating-panel", panel.id())); + let mut output = None; + egui::Area::new(id).order(egui::Order::Middle).fixed_pos(rect.min).constrain(false).interactable(true).show(ctx, |ui| { + let (rect, _) = ui.allocate_exact_size(rect.size(), Sense::hover()); + let compact = floating.panels.len() == 1 && panel.compact(); + let header = if compact { 8.0 } else { t.tab_h }; + let strip = Rect::from_min_size(rect.min, vec2(rect.width(), header)); + let response = ui.interact(strip, id.with("move"), Sense::drag()); + self.auto.add_response(&format!("panel.float.{index}.move"), &response, "Move floating panel"); + let mut next = if response.dragged() { rect.translate(response.drag_delta()) } else { rect }; + let group = Group { + path: format!("f{index}"), + rect, + content: Rect::from_min_max(pos2(rect.min.x, rect.min.y + header), rect.max), + panels: floating.panels.clone(), + active: floating.active, + }; + let chrome = dock::draw_group_chrome_details(ui, &group, self.ui.focused, seqs, &t, &mut self.auto); + actions.extend(chrome.actions); + if let Some(active) = group.panels.get(group.active).copied() { + self.panel_body(ui, active, group.content); + } + ui.painter().rect_stroke(rect, t.radius, Stroke::new(1.0, t.focus), StrokeKind::Outside); + let grip = Rect::from_min_max(rect.max - vec2(14.0, 14.0), rect.max); + let response = ui.interact(grip, id.with("resize"), Sense::click_and_drag()); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, response.enabled(), tl!("Resize floating panel"))); + self.auto.add_response(&format!("panel.float.{index}.resize"), &response, "Resize floating panel"); + for step in 0..3 { + let offset = 4.0 + step as f32 * 4.0; + ui.painter() + .line_segment([pos2(rect.max.x - offset, rect.max.y - 2.0), pos2(rect.max.x - 2.0, rect.max.y - offset)], Stroke::new(1.0, t.text_faint)); + } + if response.hovered() || response.dragged() { + ctx.set_cursor_icon(egui::CursorIcon::ResizeNwSe); + } + let delta = floating_resize_delta(ui, &response); + if response.has_focus() { + ui.painter().rect_stroke(grip, 2.0, Stroke::new(1.5, t.focus), StrokeKind::Inside); + } + if delta != egui::Vec2::ZERO { + next = Rect::from_min_size(next.min, (next.size() + delta).max(vec2(160.0, 100.0))); + } + if next != rect { + let _ = self.dock_command(ctx, Action::MoveFloating { panel, rect: [next.min.x, next.min.y, next.width(), next.height()] }); + } + output = target(&group, chrome.tabs, false); + }); + output + } + + fn panel_drag(&mut self, ctx: &egui::Context, targets: &[DropTarget]) { + let Some(panel) = ctx.data(|data| data.get_temp::(drag_id())) else { return }; + let canceled = + ctx.input(|input| input.key_pressed(egui::Key::Escape) || !input.focused || (!input.pointer.primary_down() && !input.pointer.primary_released())); + if canceled || (!self.ui.dock.contains(panel) && !self.ui.floating.iter().any(|group| group.panels.contains(&panel))) { + ctx.data_mut(|data| data.remove::(drag_id())); + return; + } + let Some(pos) = ctx.pointer_latest_pos() else { return }; + let force_float = ctx.input(|input| input.modifiers.command); + let proposal = (!force_float).then(|| resolve_drop(&panel, pos, targets)).flatten(); + let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("dock-drop"))); + let t = self.tokens; + if let Some(proposal) = &proposal { + painter.rect_filled(proposal.preview.shrink(2.0), t.radius, t.focus.gamma_multiply(0.2)); + painter.rect_stroke(proposal.preview.shrink(2.0), t.radius, Stroke::new(2.0, t.focus), StrokeKind::Inside); + if let Some(mark) = proposal.insertion { + painter.rect_filled(mark, 0.0, t.focus); + } + } + let title = painter.layout_no_wrap(panel.title().to_owned(), crate::theme::Tokens::ui(12.0), Color32::WHITE); + let ghost = Rect::from_min_size(pos + vec2(12.0, 10.0), title.size() + vec2(16.0, 8.0)); + painter.rect_filled(ghost, 4.0, t.focus.gamma_multiply(0.85)); + painter.galley(ghost.min + vec2(8.0, 4.0), title, Color32::WHITE); + ctx.set_cursor_icon(egui::CursorIcon::Grabbing); + ctx.request_repaint(); + if ctx.input(|input| input.pointer.primary_released()) { + ctx.data_mut(|data| data.remove::(drag_id())); + let action = match proposal { + Some(proposal) => Some(Action::Move { panel, anchor: proposal.anchor, placement: proposal.placement }), + None if force_float || !targets.iter().any(|target| target.rect.contains(pos)) => { + Some(Action::Float { panel, rect: [pos.x - 40.0, pos.y - 12.0, 420.0, 320.0] }) + } + None => None, + }; + if let Some(action) = action + && self.dock_command(ctx, action).is_ok() + { + self.ui.focused = panel; + } + } + } +} + +fn target(group: &Group, mut tabs: Vec<(PanelKind, Rect)>, allow_splits: bool) -> Option> { + let strip = Rect::from_min_max(group.rect.min, pos2(group.rect.max.x, group.content.min.y)); + // A compact grip still identifies the source group to the shared resolver. + for panel in &group.panels { + if !tabs.iter().any(|(candidate, _)| candidate == panel) { + tabs.push((*panel, Rect::NOTHING)); + } + } + Some(DropTarget { anchor: *group.panels.get(group.active)?, rect: group.rect, tab_strip: Some(strip), tabs, allow_splits, accepts_tabs: true }) +} + +/// A corner is a two-axis resize control; focused arrows belong to it and Tab moves on. +fn floating_resize_delta(ui: &mut egui::Ui, response: &egui::Response) -> egui::Vec2 { + if response.drag_started() { + response.request_focus(); + } + let mut delta = if response.dragged() { response.drag_delta() } else { egui::Vec2::ZERO }; + if response.has_focus() { + ui.memory_mut(|memory| { + memory.set_focus_lock_filter(response.id, egui::EventFilter { horizontal_arrows: true, vertical_arrows: true, ..Default::default() }) + }); + ui.input_mut(|input| { + for (key, amount) in [ + (egui::Key::ArrowLeft, vec2(-8.0, 0.0)), + (egui::Key::ArrowRight, vec2(8.0, 0.0)), + (egui::Key::ArrowUp, vec2(0.0, -8.0)), + (egui::Key::ArrowDown, vec2(0.0, 8.0)), + ] { + if input.consume_key(egui::Modifiers::NONE, key) { + delta += amount; + } + } + }); + } + if response.enabled() { delta } else { egui::Vec2::ZERO } +} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 08f899ecb..97ef84279 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -670,6 +670,7 @@ Extract (') Extraer (') Export Frame: use Export mode (M6) Exportar fotograma: usa el modo Exportar (M6) Button Editor Editor de botones + More transport controls Más controles de reproducción Multi-Camera Record on Grabación multicámara activada Multi-Camera Record off Grabación multicámara desactivada {first} - Merged {first} - Combinado @@ -3512,3 +3513,8 @@ Composite on the GPU when available Componer en la GPU cuando esté disponible Text to Speech Texto a voz Edit Narration Editar narración + + Dock Panel Acoplar panel + Undock Panel Desacoplar panel + Docking Action Acción de acoplamiento + Resize floating panel Cambiar tamaño del panel flotante diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index 731067f98..403baf62f 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -3494,3 +3494,6 @@ Edit Narration 编辑旁白 Clear Transition 清除过渡 Set Transition Duration 设置过渡持续时间 + Resize floating panel 调整浮动面板大小 + More transport controls 更多播放控制 + Undock Panel 取消面板停靠 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 857f42a56..fea18adb4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -36,6 +36,8 @@ pub mod control; pub mod crash; pub mod credits; pub mod dock; +mod dock_shared; +mod dock_ui; pub mod frames; pub mod header; pub mod i18n; @@ -250,6 +252,14 @@ pub struct FilmcraftApp { /// Named textures (monitors, thumbnails) with the key they show. textures: HashMap, control_rx: Option>, + /// One explicit panel reveal; consumed by the custom renderer, never repeated per frame. + pub(crate) pending_panel_raise: Option, + /// Explicit automation mode: native widget input and native menu commands are excluded. + pub(crate) control_input_only: bool, + /// Backend focus before synthetic input changes egui's effective focus for this frame. + pub(crate) native_window_focused: Option, + /// Modifiers owned by an in-flight control gesture, carried between its input frames. + synthetic_modifiers: egui::Modifiers, /// Requests waiting for the UI to show an element: (request, give-up time). deferred: Vec<(ControlRequest, f64)>, last_ui_time: f64, @@ -458,6 +468,10 @@ impl FilmcraftApp { auto: Default::default(), textures: HashMap::new(), control_rx: None, + pending_panel_raise: None, + control_input_only: false, + native_window_focused: None, + synthetic_modifiers: egui::Modifiers::NONE, deferred: Vec::new(), last_ui_time: 0.0, synthetic: Vec::new(), @@ -503,6 +517,13 @@ impl FilmcraftApp { self } + /// Isolate an automation-owned app from native keyboard, pointer, drop and menu input. + /// Window close/quit and screenshot delivery remain available. + pub fn with_control_input_only(mut self, rx: Receiver) -> Self { + self.control_input_only = true; + self.with_control(rx) + } + pub fn services(&self) -> Arc { self.session.services.clone() } @@ -694,7 +715,11 @@ impl FilmcraftApp { pub fn set_workspace(&mut self, name: &str) { self.ui.workspace = name.to_string(); - self.ui.dock = dock::saved_layout(&self.workspaces, name); + let layout = dock::saved_docking(&self.workspaces, name); + self.ui.dock = layout.root; + self.ui.floating = layout.floating; + self.pending_panel_raise = None; + self.ui.keys.maximized = None; if self.workspaces.current != name { let mut next = self.workspaces.clone(); next.current = name.to_string(); @@ -708,10 +733,10 @@ impl FilmcraftApp { } pub fn show_panel(&mut self, p: PanelKind) { - if p == PanelKind::Timeline { + if p == PanelKind::Timeline && !self.ui.floating.iter().any(|group| group.panels.contains(&p)) { self.ui.dock.restore_timeline(); } - if !self.ui.dock.contains(p) { + if !self.ui.dock.contains(p) && !self.ui.floating.iter().any(|group| group.panels.contains(&p)) { let near = match p { // a side panel beside Properties (or Essential Sound), never over the Program monitor PanelKind::TextToSpeech => [PanelKind::Properties, PanelKind::EssentialSound, PanelKind::EssentialGraphics] @@ -732,7 +757,7 @@ impl FilmcraftApp { }; self.ui.dock.open_near(p, near); } - self.ui.dock.activate(p); + let _ = self.apply_dock(craft_ui::docking::Action::Activate { panel: p }); self.ui.focused = p; } @@ -1451,11 +1476,13 @@ impl FilmcraftApp { filmcraft_engine::Event::OpenSequence(_) => { // show the sequence with its own view (or fitted, the first time) self.timeline_view_of = None; - self.ui.dock.restore_timeline(); - self.ui.dock.activate(PanelKind::Timeline); + if !self.ui.floating.iter().any(|group| group.panels.contains(&PanelKind::Timeline)) { + self.ui.dock.restore_timeline(); + } + let _ = self.apply_dock(craft_ui::docking::Action::Activate { panel: PanelKind::Timeline }); } filmcraft_engine::Event::OpenSource(_) => { - self.ui.dock.activate(PanelKind::Source); + let _ = self.apply_dock(craft_ui::docking::Action::Activate { panel: PanelKind::Source }); } filmcraft_engine::Event::RevealInProject(item) => self.reveal_in_project(item), filmcraft_engine::Event::Toast { message, .. } => self.toast = Some((message, ctx.input(|i| i.time))), @@ -1466,6 +1493,9 @@ impl FilmcraftApp { self.handle_drops(&ctx); if let Some(rx) = self.command_inbox.take() { while let Ok(id) = rx.try_recv() { + if self.control_input_only { + continue; + } if let Err(e) = menus::invoke(self, &ctx, &id, json!({})) { self.ui.status = e; } @@ -1609,79 +1639,6 @@ impl FilmcraftApp { } .to_string() } - - fn dock_area(&mut self, ui: &mut egui::Ui, body: egui::Rect) { - let t = self.tokens; - // Maximize or Restore Frame (` / Shift+`): the maximized panel fills the dock area. - let maximized = self.ui.keys.maximized.filter(|p| self.ui.dock.contains(*p)); - let mut dock = match maximized { - Some(p) => dock::DockNode::Tabs { panels: vec![p], active: 0 }, - None => std::mem::replace(&mut self.ui.dock, dock::DockNode::Tabs { panels: vec![], active: 0 }), - }; - let mut groups = Vec::new(); - dock::layout(ui, &mut dock, body, &t, "", &mut groups, &mut self.auto); - let mut actions = Vec::new(); - let seqs = dock::SeqTabs { - open: self - .session - .state - .open_sequences - .iter() - .filter_map(|id| { - self.session.project.item(*id).filter(|i| matches!(i.kind, filmcraft_project::ItemKind::Sequence(_))).map(|i| (id.0, i.name.clone())) - }) - .collect(), - active: self.session.state.active_sequence.map(|i| i.0), - }; - for g in &groups { - actions.extend(dock::draw_group_chrome(ui, g, self.ui.focused, &seqs, &t, &mut self.auto)); - } - if maximized.is_none() { - self.ui.dock = dock; - } - for g in &groups { - let Some(p) = g.panels.get(g.active).copied() else { continue }; - self.auto.add(&format!("panel.{}", p.id()), g.content, p.title()); - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(g.content).id_salt(("panel", p.id()))); - child.set_clip_rect(g.content); - panels::show(self, &mut child, p, g.content); - } - for a in actions { - match a { - dock::DockAction::Activate(p) => { - self.ui.dock.activate(p); - } - dock::DockAction::Focus(p) => self.ui.focused = p, - dock::DockAction::Close(p) => self.ui.dock.close(p), - dock::DockAction::OpenSequence(id) => { - if self.session.state.active_sequence.map(|i| i.0) != Some(id) - && let Err(e) = self.session.execute("sequence.open", json!({"item": id})) - { - self.ui.status = e.to_string(); - } - } - dock::DockAction::CloseSequence(id) => { - if let Err(e) = self.session.execute("sequence.close", json!({"item": id})) { - self.ui.status = e.to_string(); - } - } - dock::DockAction::MoveSequence(id, index) => { - if let Err(e) = self.session.execute("sequence.moveTab", json!({"item": id, "index": index})) { - self.ui.status = e.to_string(); - } - } - dock::DockAction::PanelMenu(p, pos) => { - // (with the frame it opened in: the click that opens it is not a click elsewhere) - let frame = ui.ctx().cumulative_frame_nr(); - ui.ctx().data_mut(|d| { - d.insert_temp(egui::Id::new("panel-menu"), (p, pos)); - d.insert_temp(egui::Id::new("panel-menu-opened"), frame); - }); - } - } - } - panels::panel_menu_popup(self, ui); - } } impl FilmcraftApp { @@ -1723,6 +1680,21 @@ impl FilmcraftApp { impl eframe::App for FilmcraftApp { fn raw_input_hook(&mut self, _ctx: &egui::Context, raw_input: &mut egui::RawInput) { + self.native_window_focused = Some(raw_input.focused); + if self.control_input_only { + // Preserve screenshot responses and all viewport lifecycle/geometry metadata. + raw_input.events.retain(|event| matches!(event, egui::Event::Screenshot { .. })); + raw_input.hovered_files.clear(); + raw_input.dropped_files.clear(); + } + if self.control_input_only || !self.synthetic.is_empty() { + // Native modifiers cannot replace those requested by a control gesture. A queued + // reset runs in the frame after release, so clicked() still sees Shift/Alt. + raw_input.events.push(egui::Event::ModifiersChanged(self.synthetic_modifiers)); + // Widget keyboard handling requires effective egui focus. Control input must work + // in a background window without activating it or taking the user's OS keyboard. + raw_input.focused = true; + } if !self.synthetic.is_empty() { // Pointer events go one per frame so egui sees press → moves → release as a real drag // (all in one frame reads as a click); key/text runs go together up to a key release. @@ -1735,7 +1707,12 @@ 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)); + for event in self.synthetic.drain(..n) { + if let egui::Event::ModifiersChanged(modifiers) = event { + self.synthetic_modifiers = modifiers; + } + raw_input.events.push(event); + } } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9dff12d38..fbd509f72 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -22,6 +22,10 @@ macro_rules! uic { } pub const UI_COMMANDS: &[UiCommand] = &[ + uic!("window.dockAction", "Docking Action", [], None), + uic!("window.dockPanel", "Dock Panel", [], None), + uic!("window.floatPanel", "Undock Panel", [], None), + uic!("window.closePanel", "Close Panel", [], None), uic!("app.language.english", "English", ["Edit", "Language"], None), uic!("app.language.japanese", "日本語", ["Edit", "Language"], None), uic!("app.language.spanish", "Español", ["Edit", "Language"], None), @@ -225,6 +229,9 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: // the engine's counters plus playback, frame workers and UI timings return Ok(crate::perf::stats(app)); } + if let Some(result) = crate::dock_ui::route(app, ctx, id, ¶ms) { + return result; + } if let Some(rest) = id.strip_prefix("window.panel.") { let p = PanelKind::from_name(rest).ok_or_else(|| format!("unknown panel `{rest}`"))?; app.show_panel(p); diff --git a/crates/ui-egui/src/panels/keyboard.rs b/crates/ui-egui/src/panels/keyboard.rs index 674769d6f..664740613 100644 --- a/crates/ui-egui/src/panels/keyboard.rs +++ b/crates/ui-egui/src/panels/keyboard.rs @@ -235,7 +235,7 @@ pub fn route(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: &Val fn toggle_maximize(app: &mut FilmcraftApp, p: PanelKind) -> Value { app.ui.keys.maximized = if app.ui.keys.maximized.is_some() { None } else { Some(p) }; if let Some(p) = app.ui.keys.maximized { - app.ui.dock.activate(p); + let _ = app.apply_dock(craft_ui::docking::Action::Activate { panel: p }); app.ui.focused = p; } json!({"maximized": app.ui.keys.maximized.map(|p| p.title())}) @@ -244,7 +244,7 @@ fn toggle_maximize(app: &mut FilmcraftApp, p: PanelKind) -> Value { /// The visible panel under the pointer (from last frame's automation rects). fn panel_under_pointer(app: &FilmcraftApp, ctx: &egui::Context) -> Option { let pos = ctx.input(|i| i.pointer.latest_pos())?; - app.auto.query("panel.").into_iter().find_map(|e| { + app.auto.query("panel.").into_iter().rev().find_map(|e| { let r = egui::Rect::from_min_size(egui::pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3])); r.contains(pos).then(|| PanelKind::from_name(&e.id["panel.".len()..])).flatten() }) @@ -254,7 +254,9 @@ fn panel_under_pointer(app: &FilmcraftApp, ctx: &egui::Context) -> Option Vec { let mut all = Vec::new(); app.ui.dock.panels(&mut all); - all.into_iter().filter(|p| app.ui.dock.is_visible(*p) && !p.compact()).collect() + let mut visible: Vec<_> = all.into_iter().filter(|p| app.ui.dock.is_visible(*p) && !p.compact()).collect(); + visible.extend(app.ui.floating.iter().filter_map(|group| group.panels.get(group.active).copied()).filter(|panel| !panel.compact())); + visible } fn cycle_panel(app: &mut FilmcraftApp, next: bool) -> Value { diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index bcb386fbb..d3186d221 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -224,54 +224,64 @@ pub fn panel_menu_popup(app: &mut FilmcraftApp, ui: &mut egui::Ui) { let mut close = false; let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(pos).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { - ui.set_min_width(190.0); - // the Timeline's tabs are its open sequences: Close Panel closes the active one and - // keeps the panel (Premiere's wording and behaviour) - if p == PanelKind::Timeline && app.session.state.active_sequence.is_some() { - let r = ui.button(tl!("Close Panel")); - app.auto.add("panel.menu.Timeline.close", r.rect, "Close Panel"); - if r.clicked() { - let _ = app.session.execute("sequence.close", serde_json::json!({})); - close = true; - } - let r = ui.add_enabled(app.session.state.open_sequences.len() > 1, egui::Button::new(tl!("Close Other Timeline Panels"))); - app.auto.add("panel.menu.Timeline.closeOthers", r.rect, "Close Other Timeline Panels"); - if r.clicked() { - let _ = app.session.execute("sequence.closeOthers", serde_json::json!({})); - close = true; - } - } else if ui.button(tl!("Close Panel")).clicked() { - 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 }; - close = true; - } - if ui.button(tl!("Restore Workspace")).clicked() { - let w = app.ui.workspace.clone(); - app.set_workspace(&w); - close = true; - } - if p == PanelKind::Timeline { - ui.separator(); - let r = ui.add_enabled(app.session.state.active_sequence.is_some(), egui::Button::new(tl!("Reveal Sequence in Project"))); - app.auto.add("panel.menu.Timeline.revealSequence", r.rect, "Reveal Sequence in Project"); - if r.clicked() { - let _ = app.session.execute("sequence.revealInProject", serde_json::json!({})); - close = true; - } - ui.checkbox(&mut app.ui.timeline.show_thumbnails, tl!("Video Thumbnails")); - ui.checkbox(&mut app.ui.timeline.show_waveforms, tl!("Audio Waveforms")); - } - if p == PanelKind::Project { - ui.separator(); - close |= project::panel_menu(app, ui); - } - if p == PanelKind::MediaBrowser { - ui.separator(); - close |= media_browser::panel_menu(app, ui); - } + let scroll = + egui::ScrollArea::vertical().id_salt("panel-menu-scroll").max_height((ui.ctx().content_rect().height() - 16.0).max(0.0)).show(ui, |ui| { + ui.set_min_width(190.0); + // the Timeline's tabs are its open sequences: Close Panel closes the active one and + // keeps the panel (Premiere's wording and behaviour) + if p == PanelKind::Timeline && app.session.state.active_sequence.is_some() { + let r = ui.button(tl!("Close Panel")); + app.auto.add("panel.menu.Timeline.close", r.rect, "Close Panel"); + if r.clicked() { + let _ = app.session.execute("sequence.close", serde_json::json!({})); + close = true; + } + let r = ui.add_enabled(app.session.state.open_sequences.len() > 1, egui::Button::new(tl!("Close Other Timeline Panels"))); + app.auto.add("panel.menu.Timeline.closeOthers", r.rect, "Close Other Timeline Panels"); + if r.clicked() { + let _ = app.session.execute("sequence.closeOthers", serde_json::json!({})); + close = true; + } + } else if ui.button(tl!("Close Panel")).clicked() { + let _ = crate::menus::invoke(app, ui.ctx(), "window.closePanel", serde_json::json!({"panel": p.id()})); + close = true; + } + let undock = ui.button(tl!("Undock Panel")); + app.auto.add_response(&format!("panel.menu.{}.undock", p.id()), &undock, "Undock Panel"); + if undock.clicked() { + let _ = crate::menus::invoke(app, ui.ctx(), "window.floatPanel", serde_json::json!({"panel": p.id()})); + close = true; + } + if ui.button(tl!("Maximize Frame")).clicked() { + app.ui.keys.maximized = if app.ui.keys.maximized == Some(p) { None } else { Some(p) }; + close = true; + } + if ui.button(tl!("Restore Workspace")).clicked() { + let w = app.ui.workspace.clone(); + app.set_workspace(&w); + close = true; + } + if p == PanelKind::Timeline { + ui.separator(); + let r = ui.add_enabled(app.session.state.active_sequence.is_some(), egui::Button::new(tl!("Reveal Sequence in Project"))); + app.auto.add("panel.menu.Timeline.revealSequence", r.rect, "Reveal Sequence in Project"); + if r.clicked() { + let _ = app.session.execute("sequence.revealInProject", serde_json::json!({})); + close = true; + } + ui.checkbox(&mut app.ui.timeline.show_thumbnails, tl!("Video Thumbnails")); + ui.checkbox(&mut app.ui.timeline.show_waveforms, tl!("Audio Waveforms")); + } + if p == PanelKind::Project { + ui.separator(); + close |= project::panel_menu(app, ui); + } + if p == PanelKind::MediaBrowser { + ui.separator(); + close |= media_browser::panel_menu(app, ui); + } + }); + app.auto.add("panel.menu.scroll", scroll.inner_rect, "Panel menu"); }); }); // 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 11f4bc2d8..3acc7aeaf 100644 --- a/crates/ui-egui/src/panels/monitor.rs +++ b/crates/ui-egui/src/panels/monitor.rs @@ -28,7 +28,10 @@ pub enum Which { pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) { let t = app.tokens; let ctx = ui.ctx().clone(); - let controls_h = 28.0 + 24.0 + 36.0; + let stacked_controls = rect.width() < 540.0; + let separate_source_drag = which == Which::Source && stacked_controls && rect.width() < 310.0; + let source_drag_h = if separate_source_drag { 26.0 } else { 0.0 }; + let controls_h = 28.0 + 24.0 + 36.0 + if stacked_controls { 26.0 } else { 0.0 } + source_drag_h; let video_area = Rect::from_min_max(rect.min + vec2(4.0, 4.0), pos2(rect.max.x - 4.0, rect.max.y - controls_h)); // the Source monitor's time ruler starts at `origin` (a subclip restricted to its range) let mut origin = Tick::ZERO; @@ -323,8 +326,16 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) app.auto.add(&format!("{prefix}.timecode"), tc_rect, &tc); let dur_tc = format_time(mark_out.map(|o| o + rate.frame_duration()).unwrap_or(duration) - range_start, rate, drop_frame, TimeDisplay::Timecode, 48000); ui.painter().text(pos2(row1.max.x, row1.center().y), Align2::RIGHT_CENTER, &dur_tc, Tokens::timecode(), t.text_dim); - // zoom + resolution dropdowns centred-ish - let zr = Rect::from_min_size(pos2(row1.min.x + 116.0, row1.min.y), vec2(70.0, 24.0)); + // At compact widths, timecodes and view settings need separate rows. + let settings_y = row1.min.y + if stacked_controls { 26.0 } else { 0.0 }; + let zoom_x = if stacked_controls && which == Which::Source && !separate_source_drag { + row1.min.x + } else if stacked_controls { + row1.center().x - 83.0 + } else { + row1.min.x + 116.0 + }; + let zr = Rect::from_min_size(pos2(zoom_x, settings_y), vec2(70.0, 24.0)); let zoom_label = match mv.zoom { None => tl!("Fit").to_string(), Some(z) => format!("{}%", (z * 100.0).round() as i32), @@ -333,9 +344,19 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) app.auto.add(&format!("{prefix}.zoom"), zr, "Select Zoom Level"); monitor_view::zoom_menu(app, &zresp, which); let res = mv.res; - let rr = Rect::from_min_size(pos2(row1.max.x - 196.0, row1.min.y), vec2(62.0, 24.0)); + let resolution_x = if stacked_controls && which == Which::Source && !separate_source_drag { + row1.max.x - 90.0 + } else if stacked_controls { + zr.max.x + 6.0 + } else { + row1.max.x - 196.0 + }; + let rr = Rect::from_min_size(pos2(resolution_x, settings_y), vec2(62.0, 24.0)); if which == Which::Source && rr.min.x > zr.max.x + 12.0 { - crate::panels::source_drag::controls(app, ui, Rect::from_min_max(pos2(zr.max.x + 6.0, row1.min.y), pos2(rr.min.x - 6.0, row1.min.y + 24.0))); + crate::panels::source_drag::controls(app, ui, Rect::from_min_max(pos2(zr.max.x + 6.0, settings_y), pos2(rr.min.x - 6.0, settings_y + 24.0))); + } + if separate_source_drag { + crate::panels::source_drag::controls(app, ui, Rect::from_min_size(pos2(row1.center().x - 52.0, settings_y + 26.0), vec2(104.0, 24.0))); } let rresp = crate::widgets::dropdown_text(ui, rr, crate::i18n::t(res.label()), &t, egui::Id::new((prefix, "res"))); app.auto.add(&format!("{prefix}.resolution"), rr, "Select Playback Resolution"); @@ -346,7 +367,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) } } }); - let wr = Rect::from_min_size(pos2(rr.max.x + 6.0, row1.min.y + 1.0), vec2(22.0, 22.0)); + let wr = Rect::from_min_size(pos2(rr.max.x + 6.0, settings_y + 1.0), vec2(22.0, 22.0)); let wresp = ui.interact(wr, egui::Id::new((prefix, "wrench")), Sense::click()); icons::paint(ui.painter(), wr.shrink(4.0), Icon::Wrench, if wresp.hovered() { t.tab_text_active } else { t.icon }); app.auto.add(&format!("{prefix}.settings"), wr, "Settings"); @@ -402,7 +423,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) } }); // ---- mini timeline / scrub bar - let bar = Rect::from_min_size(pos2(rect.min.x + 14.0, row1.max.y + 2.0), vec2(rect.width() - 28.0, 22.0)); + let bar = Rect::from_min_size(pos2(rect.min.x + 14.0, settings_y + 28.0 + source_drag_h), vec2(rect.width() - 28.0, 22.0)); mini_timeline(app, ui, bar, which, origin, time, duration, rate, mark_in, mark_out); // ---- transport buttons @@ -726,51 +747,107 @@ fn transport(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect, which: Which) (Icon::Proxy, "media.toggleProxies", tl!("Toggle Proxies")), ] }; - let bw = 30.0; - let total = buttons.len() 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 { - 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); - let is_play = cmd.ends_with("play") || cmd == "playback.toggle"; - if resp.hovered() { - ui.painter().rect_filled(r, 4.0, t.hover); + let available = (row.width() - 16.0).max(0.0); + let widths = vec![28.0; buttons.len()]; + let Some(fit) = craft_ui::layout::fit_toolbar(&widths, available, 2.0, 28.0) else { + ui.scope_builder(egui::UiBuilder::new().max_rect(row), |ui| { + egui::ScrollArea::horizontal().id_salt((prefix, "transport-scroll")).show(ui, |ui| { + ui.horizontal(|ui| { + for (icon, command, label) in &buttons { + let (rect, _) = ui.allocate_exact_size(vec2(28.0, 26.0), Sense::hover()); + if transport_button(app, ui, rect, prefix, *icon, command, label) { + invoke_transport(app, ui.ctx(), which, command); + } + } + }); + }); + }); + return; + }; + let total = fit.shown.len() as f32 * 30.0 - 2.0 + if fit.overflow.is_empty() { 0.0 } else { 30.0 }; + let mut x = row.center().x - total.max(28.0) / 2.0; + for (index, _) in &fit.shown { + let Some((icon, command, label)) = buttons.get(*index) else { continue }; + let rect = Rect::from_min_size(pos2(x, row.min.y + 2.0), vec2(28.0, 26.0)); + if transport_button(app, ui, rect, prefix, *icon, command, label) { + invoke_transport(app, ui.ctx(), which, command); } - let sz = if is_play { 16.0 } else { 14.0 }; - let on = cmd == "media.toggleProxies" && app.session.media.use_proxies(); - let col = if on { - t.accent - } else if resp.hovered() { - t.tab_text_active - } else { - t.icon - }; - icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(sz, sz)), icon, col); - if resp.clicked() { - let r = match cmd { - "src.markIn" => app.session.execute("markers.markIn", json!({"target": "source"})).map_err(|e| e.to_string()), - "src.markOut" => app.session.execute("markers.markOut", json!({"target": "source"})).map_err(|e| e.to_string()), - "src.goIn" | "src.goOut" | "src.stepBack" | "src.stepFwd" => { - source_nav(app, cmd); - Ok(serde_json::Value::Null) + x += 30.0; + } + if !fit.overflow.is_empty() { + let rect = Rect::from_min_size(pos2(x, row.min.y + 2.0), vec2(28.0, 26.0)); + let response = ui.interact(rect, egui::Id::new((prefix, "transport-more")), Sense::click()).on_hover_text(tl!("More transport controls")); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("More transport controls"))); + if response.hovered() { + ui.painter().rect_filled(rect, 4.0, t.hover); + } + for offset in [-5.0, 0.0, 5.0] { + ui.painter().circle_filled(rect.center() + vec2(offset, 0.0), 1.5, t.icon); + } + if response.has_focus() { + ui.painter().rect_stroke(rect.shrink(2.0), 2.0, Stroke::new(1.0, t.focus), StrokeKind::Inside); + } + app.auto.add_response(&format!("{prefix}.transport.more"), &response, "More transport controls"); + egui::Popup::menu(&response).show(|ui| { + for index in &fit.overflow { + let Some((_, command, label)) = buttons.get(*index) else { continue }; + let selected = match *command { + "media.toggleProxies" => Some(app.session.media.use_proxies()), + "playback.toggle" => Some(app.playback.playing), + "source.playback.toggle" => Some(app.source_playback.clock.playing), + _ => None, + }; + let response = if let Some(selected) = selected { ui.selectable_label(selected, *label) } else { ui.button(*label) }; + app.auto.add_response(&format!("{prefix}.transport.{command}"), &response, label); + if response.clicked() { + invoke_transport(app, ui.ctx(), which, command); + ui.close(); } + } + }); + } else if row.right() - x >= 30.0 { + // Keep the existing button-editor affordance only when it has its own space. + let rect = Rect::from_min_size(pos2(row.max.x - 30.0, row.min.y + 4.0), vec2(22.0, 22.0)); + let response = ui.interact(rect, egui::Id::new((prefix, "btn-editor")), Sense::click()).on_hover_text(tl!("Button Editor")); + icons::paint(ui.painter(), rect.shrink(5.0), Icon::Plus, if response.hovered() { t.tab_text_active } else { t.text_dim }); + } +} + +fn transport_button(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, prefix: &str, icon: Icon, command: &str, label: &str) -> bool { + let t = app.tokens; + let response = ui.interact(rect, egui::Id::new((prefix, command)), Sense::click()).on_hover_text(label); + app.auto.add_response(&format!("{prefix}.transport.{command}"), &response, label); + if response.hovered() { + ui.painter().rect_filled(rect, 4.0, t.hover); + } + let size = if matches!(command, "playback.toggle" | "source.playback.toggle") { 16.0 } else { 14.0 }; + let color = if command == "media.toggleProxies" && app.session.media.use_proxies() { + t.accent + } else if response.hovered() { + t.tab_text_active + } else { + t.icon + }; + icons::paint(ui.painter(), Rect::from_center_size(rect.center(), vec2(size, size)), icon, color); + response.clicked() +} - "exportFrame" => crate::menus::invoke(app, &ctx, "file.exportFrame", json!({"monitor": if src { "source" } else { "program" }})), - c => crate::menus::invoke(app, &ctx, c, json!({"monitor": if src { "source" } else { "program" }})), - }; - if let Err(e) = r { - app.ui.status = e; - } +fn invoke_transport(app: &mut FilmcraftApp, ctx: &egui::Context, which: Which, command: &str) { + let source = which == Which::Source; + let result = match command { + "src.markIn" => app.session.execute("markers.markIn", json!({"target": "source"})).map_err(|error| error.to_string()), + "src.markOut" => app.session.execute("markers.markOut", json!({"target": "source"})).map_err(|error| error.to_string()), + "src.goIn" | "src.goOut" | "src.stepBack" | "src.stepFwd" => { + source_nav(app, command); + Ok(serde_json::Value::Null) } - x += bw; + "exportFrame" => crate::menus::invoke(app, ctx, "file.exportFrame", json!({"monitor": if source { "source" } else { "program" }})), + command => crate::menus::invoke(app, ctx, command, json!({"monitor": if source { "source" } else { "program" }})), + }; + if let Err(error) = result { + app.ui.status = error; } - // button editor "+" - 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")); - icons::paint(ui.painter(), r.shrink(5.0), Icon::Plus, if resp.hovered() { t.tab_text_active } else { t.text_dim }); } fn source_nav(app: &mut FilmcraftApp, cmd: &str) { diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index d59978393..60732fc18 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -566,6 +566,7 @@ fn empty_state(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { // accept drops from the project panel if let Some(item) = crate::panels::dragged_project_item(ui) && ui.rect_contains_pointer(rect) + && ui.input(|i| i.pointer.hover_pos()).is_some_and(|pos| app.timeline_drop_target(ui.ctx(), pos)) && ui.input(|i| i.pointer.any_released()) { let _ = app.session.execute("file.newSequence", json!({"fromItem": item.0})); @@ -2286,6 +2287,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & if let Some(item) = crate::panels::dragged_project_item(ui) && let Some(p) = ctx.pointer_hover_pos() && layout.content.contains(p) + && app.timeline_drop_target(ui.ctx(), p) { let source = crate::panels::dragged_source(ui); let row = layout.row_at(p.y).cloned().filter(|r| { @@ -2333,6 +2335,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & if let Some(template) = crate::panels::dragged_template(ui) && let Some(p) = ctx.pointer_hover_pos() && layout.content.contains(p) + && app.timeline_drop_target(ui.ctx(), p) { let t = snap(app, seq, layout, rate.snap_nearest(layout.tick_at(p.x).max(Tick::ZERO)), &[]); let row = layout.row_at(p.y).cloned().filter(|r| r.kind == TrackKind::Video); @@ -2354,6 +2357,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & if let Some(effect) = crate::panels::dragged_effect(ui) && let Some(p) = ctx.pointer_hover_pos() && layout.content.contains(p) + && app.timeline_drop_target(ui.ctx(), p) && let Hit::Clip { clip, .. } = hit(seq, layout, p) && let Some((tid, it)) = seq.find_item(clip) && let Some(row) = layout.rows.iter().find(|r| r.track == tid) @@ -2408,6 +2412,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & // ---- OS file drops imported with "drop imports to the timeline": placed at the drop point, one after another if let Some((items, p)) = app.pending_timeline_drop.clone() && layout.content.contains(p) + && app.timeline_drop_target(ui.ctx(), p) { app.pending_timeline_drop = None; let row = layout.row_at(p.y).cloned().or_else(|| layout.rows.iter().find(|r| r.kind == TrackKind::Video).cloned()); @@ -2436,6 +2441,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & // preview of OS files being dragged over the Timeline: a placeholder where the drop would start if let Some((count, p)) = app.file_drag_hover && layout.content.contains(p) + && app.timeline_drop_target(ui.ctx(), p) { let row = layout.row_at(p.y).cloned().or_else(|| layout.rows.iter().find(|r| r.kind == TrackKind::Video).cloned()); if let Some(row) = row { @@ -2444,6 +2450,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & let r = Rect::from_min_max(pos2(x, row.rect.min.y + 1.0), pos2((x + 120.0).min(layout.content.max.x), row.rect.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); + app.auto.add("timeline.fileDropPreview", r, "tracks"); let label = if count == 1 { tl!("1 file").to_string() } else { tlf!("{n} files", n = count) }; ui.painter().text(r.left_top() + vec2(4.0, 2.0), Align2::LEFT_TOP, label, Tokens::ui(10.0), Color32::WHITE); } diff --git a/crates/ui-egui/src/panels/tools.rs b/crates/ui-egui/src/panels/tools.rs index 35afefc9d..15228a502 100644 --- a/crates/ui-egui/src/panels/tools.rs +++ b/crates/ui-egui/src/panels/tools.rs @@ -24,8 +24,14 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { format!("{} ({})", app.ui.language.tr(current.label()), current.shortcut()) }; let resp = ui.interact(r, egui::Id::new(("tool", format!("{:?}", group[0]))), Sense::click()).on_hover_text(tip); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, resp.enabled(), app.ui.tool == current, current.label())); for tl in &group { - app.auto.add(&format!("tools.{tl:?}"), r, tl.label()); + if *tl == current { + app.auto.add_response(&format!("tools.{tl:?}"), &resp, tl.label()); + } else { + // Hidden group alternatives remain click aliases, not separate focus targets. + app.auto.add(&format!("tools.{tl:?}"), r, tl.label()); + } } let active = group.contains(&app.ui.tool); if active { @@ -34,6 +40,9 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.painter().rect_filled(r, 4.0, t.hover); } icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(17.0, 17.0)), current.icon(), if active { egui::Color32::WHITE } else { t.icon }); + if resp.has_focus() { + ui.painter().rect_stroke(r.shrink(2.0), 4.0, egui::Stroke::new(1.5, t.focus), egui::StrokeKind::Inside); + } if group.len() > 1 { let c = r.right_bottom() - vec2(3.0, 3.0); ui.painter().add(egui::Shape::convex_polygon(vec![c, c - vec2(5.0, 0.0), c - vec2(0.0, 5.0)], t.text_dim, egui::Stroke::NONE)); @@ -64,7 +73,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { egui::Popup::menu(&resp).open_memory(command).close_behavior(close_behavior).show(|ui| { for tl in &group { let row = ui.selectable_label(app.ui.tool == *tl, format!("{} {}", app.ui.language.tr(tl.label()), tl.shortcut())); - app.auto.add(&format!("tools.select.{tl:?}"), row.rect, tl.label()); + app.auto.add_response(&format!("tools.select.{tl:?}"), &row, tl.label()); if row.clicked() { app.ui.tool = *tl; ui.close(); diff --git a/crates/ui-egui/src/panels/workspaces.rs b/crates/ui-egui/src/panels/workspaces.rs index 69d529441..0923316c2 100644 --- a/crates/ui-egui/src/panels/workspaces.rs +++ b/crates/ui-egui/src/panels/workspaces.rs @@ -56,12 +56,17 @@ pub fn route(app: &mut FilmcraftApp, id: &str, params: &Value) -> Option Result<(), String> { + crate::dock::Layout { root: app.ui.dock.clone(), floating: app.ui.floating.clone() }.validate()?; let layout = serde_json::to_value(&app.ui.dock).map_err(|e| e.to_string())?; + let floating = if app.ui.floating.is_empty() { serde_json::Value::Null } else { serde_json::to_value(&app.ui.floating).map_err(|e| e.to_string())? }; let mut next = app.workspaces.clone(); let saved = &mut next.saved; match saved.iter_mut().find(|s| s.name == name) { - Some(s) => s.layout = layout, - None => saved.push(dock::SavedWorkspace { name: name.to_string(), layout }), + Some(s) => { + s.layout = layout; + s.floating = floating; + } + None => saved.push(dock::SavedWorkspace { name: name.to_string(), layout, floating }), } next.current = name.to_string(); app.set_workspaces(next) diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index aacea877e..b02fae254 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -376,6 +376,8 @@ pub struct UiState { pub mode: Mode, pub workspace: String, pub dock: DockNode, + #[serde(default)] + pub floating: Vec, /// Focused panel (blue outline, receives shortcuts). pub focused: PanelKind, pub timeline: TimelineView, @@ -886,6 +888,7 @@ impl Default for UiState { tool: Tool::Selection, mode: Mode::Edit, workspace: "Editing".into(), + floating: vec![], dock: crate::dock::workspace("Editing"), focused: PanelKind::Timeline, timeline: TimelineView::default(), diff --git a/crates/ui-egui/tests/monitor_geometry.rs b/crates/ui-egui/tests/monitor_geometry.rs new file mode 100644 index 000000000..bb6711967 --- /dev/null +++ b/crates/ui-egui/tests/monitor_geometry.rs @@ -0,0 +1,185 @@ +//! Geometry and reachability checks against the production monitor renderer. + +use egui::{Event, Modifiers, PointerButton, Rect, pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use filmcraft_engine::Session; +use filmcraft_ui_egui::{ + FilmcraftApp, + dock::{self, DockNode, PanelKind, SeqTabs}, + panels::monitor::{self, Which}, + theme::{self, ThemeKind}, +}; +use serde_json::json; + +fn harness(width: f32, kind: ThemeKind, which: Which) -> Harness<'static, FilmcraftApp> { + harness_at_scale(width, kind, which, 1.0) +} + +fn harness_at_scale(width: f32, kind: ThemeKind, which: Which, scale: f32) -> Harness<'static, FilmcraftApp> { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).unwrap(); + session.execute("source.open", json!({"item":5})).unwrap(); + let mut h = Harness::builder().with_size(vec2(width, 340.0)).with_pixels_per_point(scale).build_ui_state( + move |ui, app: &mut FilmcraftApp| { + app.set_theme(ui.ctx(), kind); + app.auto.begin_frame(); + let panel = if which == Which::Source { PanelKind::Source } else { PanelKind::Program }; + let mut node = DockNode::Tabs { panels: vec![panel], active: 0 }; + let mut groups = Vec::new(); + dock::layout(ui, &mut node, ui.max_rect(), &app.tokens, "monitor", &mut groups, &mut app.auto); + for group in groups { + // Exercise the actual parent chrome, including its themed panel background. + dock::draw_group_chrome(ui, &group, panel, &SeqTabs::default(), &app.tokens, &mut app.auto); + monitor::show(app, ui, group.content, which); + } + let source = filmcraft_ui_egui::panels::dragged_source(ui); + ui.ctx().data_mut(|data| data.insert_temp(egui::Id::new("observed-source-drag"), source)); + }, + FilmcraftApp::new(session), + ); + theme::install(&h.ctx, &h.state().tokens); + h.run_steps(4); + h +} + +fn element(h: &Harness<'_, FilmcraftApp>, id: &str) -> Rect { + let element = h.state().auto.elements.iter().find(|element| element.id == id).unwrap_or_else(|| panic!("missing {id}")); + let [x, y, w, h] = element.rect; + Rect::from_min_size(pos2(x, y), vec2(w, h)) +} + +fn click(h: &mut Harness<'_, FilmcraftApp>, id: &str) { + let pos = element(h, id).center(); + h.event(Event::PointerMoved(pos)); + for pressed in [true, false] { + h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(3); +} + +fn overlaps(first: Rect, second: Rect) -> bool { + let intersection = first.intersect(second); + intersection.width() > 0.0 && intersection.height() > 0.0 +} + +#[test] +fn monitor_readouts_and_dropdowns_do_not_overlap() { + let mut failures = Vec::new(); + for (which, prefix) in [(Which::Source, "source"), (Which::Program, "program")] { + for kind in [ThemeKind::Dark, ThemeKind::Medium, ThemeKind::Light] { + for width in [720.0, 500.0, 300.0, 250.0] { + for scale in [1.0, 2.0] { + let mut h = harness_at_scale(width, kind, which, scale); + if let Ok(directory) = std::env::var("CRAFT_UI_VISUAL_DIR") { + std::fs::create_dir_all(&directory).unwrap(); + h.render().unwrap().save(format!("{directory}/film-{prefix}-{kind:?}-{width}-{scale}x.png")).unwrap(); + } + let controls = ["timecode", "zoom", "resolution", "settings"].map(|name| element(&h, &format!("{prefix}.{name}"))); + let timecodes: Vec<_> = h + .output() + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + egui::Shape::Text(text) if text.galley.text().matches(':').count() == 3 => { + Some(text.visual_bounding_rect().intersect(shape.clip_rect)) + } + _ => None, + }) + .collect(); + assert_eq!(timecodes.len(), 2, "both actual timecode galleys must be painted"); + if overlaps(timecodes[0], timecodes[1]) { + failures.push(format!("{prefix} {kind:?} {width} {scale}x: actual timecode text overlaps: {timecodes:?}")); + } + for (i, first) in controls.iter().enumerate() { + if first.left() < 0.0 || first.right() > width { + failures.push(format!("{prefix} {kind:?} {width} {scale}x: outside monitor: {first:?}")); + } + for second in controls.iter().skip(i + 1) { + if overlaps(*first, *second) { + failures.push(format!("{prefix} {kind:?} {width} {scale}x: {first:?} overlaps {second:?}")); + } + } + } + } + } + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn compact_transport_buttons_stay_inside_monitor() { + for width in [720.0, 500.0, 300.0, 250.0] { + let h = harness(width, ThemeKind::Dark, Which::Program); + for e in &h.state().auto.elements { + if e.id.starts_with("program.transport.") { + let r = element(&h, &e.id); + assert!(r.left() >= 0.0 && r.right() <= width, "{width}: {} {r:?}", e.id); + } + } + } +} + +#[test] +fn compact_transport_more_runs_the_existing_proxy_command() { + let mut h = harness(250.0, ThemeKind::Light, Which::Program); + let before = h.state().session.media.use_proxies(); + click(&mut h, "program.transport.more"); + assert_eq!( + h.get_by_label("Toggle Proxies").accesskit_node().toggled(), + Some(if before { egui::accesskit::Toggled::True } else { egui::accesskit::Toggled::False }) + ); + click(&mut h, "program.transport.media.toggleProxies"); + assert_eq!(h.state().session.media.use_proxies(), !before); + click(&mut h, "program.transport.more"); + assert_eq!( + h.get_by_label("Toggle Proxies").accesskit_node().toggled(), + Some(if before { egui::accesskit::Toggled::False } else { egui::accesskit::Toggled::True }) + ); +} + +#[test] +fn compact_source_overflow_keeps_source_navigation_routing() { + let mut h = harness(250.0, ThemeKind::Dark, Which::Source); + let before = h.state().session.state.source_playhead; + let program = h.state().session.playhead(); + click(&mut h, "source.transport.more"); + click(&mut h, "source.transport.src.goOut"); + assert!(h.state().session.state.source_playhead > before); + assert_eq!(h.state().session.playhead(), program); +} + +fn source_drag_choices(width: f32) { + for (control, video, audio) in [("video", true, false), ("audio", false, true), ("both", true, true)] { + let mut h = harness(width, ThemeKind::Dark, Which::Source); + let rect = element(&h, &format!("source.drag.{control}")); + assert!(rect.left() >= 0.0 && rect.right() <= width); + let point = rect.center(); + h.event(Event::PointerMoved(point)); + h.step(); + h.event(Event::PointerButton { pos: point, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + h.event(Event::PointerMoved(point + vec2(0.0, 10.0))); + h.run_steps(2); + let source = h + .ctx + .data(|data| data.get_temp::>(egui::Id::new("observed-source-drag"))) + .flatten() + .expect("drag must create the production Source payload"); + assert_eq!((source.video, source.audio), (video, audio)); + assert_eq!(source.item.0, 5); + assert!(source.range.duration.0 > 0); + } +} + +#[test] +fn source_drag_choices_remain_reachable_at_500_pixels() { + source_drag_choices(500.0); +} + +#[test] +fn source_drag_choices_remain_reachable_at_250_pixels() { + source_drag_choices(250.0); +} diff --git a/crates/ui-egui/tests/project_panel_ui.rs b/crates/ui-egui/tests/project_panel_ui.rs index bb504f32f..b8aab49db 100644 --- a/crates/ui-egui/tests/project_panel_ui.rs +++ b/crates/ui-egui/tests/project_panel_ui.rs @@ -322,8 +322,21 @@ fn freeform_view_places_and_stacks_cards() { assert!(d.has(&format!("project.stack.{}", items[0])), "×2 badge"); // Freeform View Options… from the panel menu d.click("panel.menu.Project"); + d.ok("ui.scroll", json!({"id": "panel.menu.scroll", "dy": -1200})); + d.frames(4); + let item_rect = d.rect("project.menu.freeformOptions"); + let ctx = d.harness.ctx.clone(); + let at = egui::pos2(item_rect[0] + item_rect[2] / 2.0, item_rect[1] + item_rect[3] / 2.0); + assert!(ctx.content_rect().contains(at), "bottom menu action must be reachable: {item_rect:?}"); + d.snapshot("project-freeform-menu", None); d.click("project.menu.freeformOptions"); - assert!(d.has("projectDialog.snap")); + let app = d.app(); + let diagnostic = format!( + "dialog={:?}, controls={:?}", + app.ui.project_panel.dialog, + app.auto.elements.iter().filter(|element| element.id.starts_with("projectDialog.")).map(|element| (&element.id, element.rect)).collect::>() + ); + assert!(d.has("projectDialog.snap"), "{diagnostic}"); d.click("projectDialog.snap"); d.click("projectDialog.ok"); assert!(d.app().session.prefs.project_panel.freeform.snap); @@ -358,6 +371,8 @@ fn metadata_display_dialog_and_view_presets() { ] { assert!(d.has(id), "{id}"); } + d.ok("ui.scroll", json!({"id": "panel.menu.scroll", "dy": -1200})); + d.frames(4); d.click("project.menu.metadataDisplay"); d.frames(4); assert!(matches!(d.app().ui.project_panel.dialog, Some(filmcraft_ui_egui::panels::project::ProjectDialog::MetadataDisplay { .. }))); @@ -573,6 +588,8 @@ fn media_browser_navigation_import_and_columns() { assert!(d.label("mediaBrowser.count").starts_with("2 of 2")); d.snapshot("media-browser-list", Some("panel.MediaBrowser")); d.click("panel.menu.MediaBrowser"); + d.ok("ui.scroll", json!({"id": "panel.menu.scroll", "dy": -1200})); + d.frames(4); d.click("mediaBrowser.menu.import"); assert_eq!(d.app().session.project.items.len(), 2); // Shift+O opens the selection in the Source monitor (already imported: no duplicate) diff --git a/crates/ui-egui/tests/shared_docking_ui.rs b/crates/ui-egui/tests/shared_docking_ui.rs new file mode 100644 index 000000000..fcabbdd04 --- /dev/null +++ b/crates/ui-egui/tests/shared_docking_ui.rs @@ -0,0 +1,515 @@ +//! Shared docking preserves FilmCraft document state and legacy workspace files. + +use craft_ui::docking::{Action, Placement, Zone}; +use egui::{Event, Modifiers, PointerButton, Rect, pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use filmcraft_engine::Session; +use filmcraft_ui_egui::{ + FilmcraftApp, + dock::{self, DockNode, Layout, PanelKind, SplitSize}, + menus, +}; +use serde_json::json; + +fn layout() -> Layout { + Layout { + root: DockNode::Split { + vertical: false, + size: SplitSize::FixedA(400.0), + a: Box::new(DockNode::Tabs { panels: vec![PanelKind::Project, PanelKind::Info, PanelKind::History], active: 1 }), + b: Box::new(DockNode::Tabs { panels: vec![PanelKind::Source, PanelKind::Program], active: 1 }), + }, + floating: vec![], + } +} + +#[test] +fn shared_transactions_keep_selection_and_reject_invalid_moves_atomically() { + let mut layout = layout(); + layout.apply(Action::Close { panel: PanelKind::Project }).unwrap(); + assert!(layout.root.is_visible(PanelKind::Info)); + let before = layout.clone(); + for action in [ + Action::Move { panel: PanelKind::Info, anchor: PanelKind::Tools, placement: Placement::Split(Zone::Left) }, + Action::Float { panel: PanelKind::Info, rect: [0.0, 0.0, f32::NAN, 100.0] }, + Action::Move { panel: PanelKind::Info, anchor: PanelKind::Program, placement: Placement::Tab { before: Some(PanelKind::Tools) } }, + ] { + assert!(layout.apply(action).is_err()); + assert_eq!(layout, before); + } + layout.apply(Action::Float { panel: PanelKind::Info, rect: [50.0, 50.0, 400.0, 300.0] }).unwrap(); + layout.apply(Action::Move { panel: PanelKind::Info, anchor: PanelKind::Program, placement: Placement::Split(Zone::Bottom) }).unwrap(); + assert!(layout.floating.is_empty()); + assert!(layout.root.is_visible(PanelKind::Info)); +} + +#[test] +fn builtin_and_legacy_workspace_shapes_roundtrip_and_save_floating() { + for name in dock::WORKSPACES { + let root = dock::workspace(name); + let before = serde_json::to_value(&root).unwrap(); + let prefs: dock::WorkspacePrefs = serde_json::from_value(json!({"saved": [{"name": name, "layout": before}], "current": name})).unwrap(); + let loaded = dock::saved_docking(&prefs, name); + loaded.validate().unwrap(); + assert_eq!(loaded.root, root); + assert!(loaded.floating.is_empty()); + assert!(serde_json::to_value(&prefs).unwrap()["saved"][0].get("floating").is_none()); + } + let dir = std::env::temp_dir().join(format!("filmcraft-shared-dock-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let mut session = Session::default(); + session.prefs_path = Some(dir.join("preferences.json")); + let mut app = FilmcraftApp::new(session); + let ctx = egui::Context::default(); + app.ui.dock = layout().root; + menus::invoke(&mut app, &ctx, "window.floatPanel", json!({"panel": "Info", "rect": [45, 65, 420, 320]})).unwrap(); + menus::invoke(&mut app, &ctx, "window.workspace.saveAs", json!({"name": "Floating test"})).unwrap(); + let prefs = dock::WorkspacePrefs::load(&dir.join(dock::WORKSPACES_FILE)); + let restored = dock::saved_docking(&prefs, "Floating test"); + assert_eq!(restored.floating, app.ui.floating); + assert_eq!(restored.root, app.ui.dock); + let mut restarted_session = Session::default(); + restarted_session.prefs_path = Some(dir.join("preferences.json")); + let restarted = FilmcraftApp::new(restarted_session); + let mut h = Harness::builder().with_size(vec2(1200.0, 760.0)).build_eframe(move |_| restarted); + for _ in 0..4 { + step(&mut h); + } + assert_eq!(h.state().ui.dock, restored.root); + assert_eq!(h.state().ui.floating, restored.floating); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn legacy_effects_workspace_drops_only_the_known_inactive_duplicate() { + let expected = dock::workspace("Effects"); + let mut legacy = expected.clone(); + fn duplicate(node: &mut DockNode) { + match node { + DockNode::Tabs { panels, active } if panels.as_slice() == [PanelKind::EssentialGraphics] => { + panels.insert(0, PanelKind::EffectControls); + *active = 1; + } + DockNode::Split { a, b, .. } => { + duplicate(a); + duplicate(b); + } + DockNode::Tabs { .. } => {} + } + } + duplicate(&mut legacy); + let prefs: dock::WorkspacePrefs = + serde_json::from_value(json!({"saved": [{"name": "Historical Effects", "layout": legacy}], "current": "Historical Effects"})).unwrap(); + assert_eq!(dock::saved_docking(&prefs, "Historical Effects").root, expected); +} + +fn step(h: &mut Harness<'_, FilmcraftApp>) { + let ctx = h.ctx.clone(); + let mut input = std::mem::take(h.input_mut()); + eframe::App::raw_input_hook(h.state_mut(), &ctx, &mut input); + *h.input_mut() = input; + h.step(); +} +fn rect(h: &Harness<'_, FilmcraftApp>, id: &str) -> Rect { + let element = h.state().auto.find(id).unwrap_or_else(|| panic!("missing {id}")); + Rect::from_min_size(pos2(element.rect[0], element.rect[1]), vec2(element.rect[2], element.rect[3])) +} +fn click(h: &mut Harness<'_, FilmcraftApp>, id: &str) { + let pos = rect(h, id).center(); + h.event(Event::PointerMoved(pos)); + step(h); + for pressed in [true, false] { + h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + step(h); + } + for _ in 0..3 { + step(h); + } +} +fn drag(h: &mut Harness<'_, FilmcraftApp>, from: egui::Pos2, to: egui::Pos2, modifiers: Modifiers) { + h.event(Event::ModifiersChanged(modifiers)); + h.event(Event::PointerMoved(from)); + step(h); + h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers }); + step(h); + for fraction in [0.05, 0.25, 0.5, 0.75, 1.0] { + h.event(Event::PointerMoved(from.lerp(to, fraction))); + step(h); + } + h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers }); + step(h); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + for _ in 0..3 { + step(h); + } +} + +#[test] +fn real_panel_drag_merges_splits_floats_and_redocks_without_editing_documents() { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).unwrap(); + let app = FilmcraftApp::new(session); + let snapshots = std::env::var_os("CRAFT_UI_DOCKING_SNAPSHOT_DIR").map(std::path::PathBuf::from); + let mut builder = Harness::builder().with_size(vec2(1200.0, 760.0)); + if snapshots.is_some() { + builder = builder.wgpu(); + } + let mut h = builder.build_eframe(move |_| app); + for _ in 0..4 { + step(&mut h); + } + h.state_mut().ui.dock = DockNode::Split { + vertical: true, + size: SplitSize::Ratio(0.62), + a: Box::new(layout().root), + b: Box::new(DockNode::Tabs { panels: vec![PanelKind::Timeline], active: 0 }), + }; + for _ in 0..3 { + step(&mut h); + } + let project = h.state().session.project.clone(); + let mut action_log = vec![action_state(&h, "start")]; + let playheads = h.state().session.state.playheads.clone(); + let from = rect(&h, "panel.tab.Info").center(); + let to = rect(&h, "panel.Program").center(); + drag(&mut h, from, to, Modifiers::NONE); + assert!(h.state().ui.dock.is_visible(PanelKind::Info)); + assert!(same_group(&h.state().ui.dock, PanelKind::Info, PanelKind::Program)); + action_log.push(action_state(&h, "merged")); + let from = rect(&h, "panel.tab.Info").center(); + let before = rect(&h, "panel.tab.Source"); + drag(&mut h, from, pos2(before.left() + 2.0, before.center().y), Modifiers::NONE); + assert_eq!(tabs_containing(&h.state().ui.dock, PanelKind::Info).first(), Some(&PanelKind::Info)); + action_log.push(action_state(&h, "reordered")); + + let from = rect(&h, "panel.tab.Info").center(); + let left = rect(&h, "panel.History"); + // Activate a visible sibling after the source left its original group. + let to = pos2(left.left() + 8.0, left.center().y); + drag(&mut h, from, to, Modifiers::NONE); + assert!(h.state().ui.dock.is_visible(PanelKind::Info)); + assert!(!same_group(&h.state().ui.dock, PanelKind::Info, PanelKind::History)); + assert!(!same_group(&h.state().ui.dock, PanelKind::Info, PanelKind::Program)); + action_log.push(action_state(&h, "split")); + click(&mut h, "panel.tab.Program"); + assert!(h.state().auto.elements.iter().any(|element| element.id.starts_with("timeline.clip."))); + assert!(h.state().auto.find("program.transport.playhead.stepForward").is_some()); + let from = rect(&h, "panel.tab.Info").center(); + drag(&mut h, from, pos2(700.0, 400.0), Modifiers::COMMAND); + assert_eq!(h.state().ui.floating.len(), 1); + assert_eq!(h.get_by_label("Resize floating panel").accesskit_node().role(), egui::accesskit::Role::Button); + action_log.push(action_state(&h, "floated")); + let original = h.state().ui.floating[0].rect; + let grip = rect(&h, "panel.float.0.resize").center(); + drag(&mut h, grip, grip + vec2(36.0, 24.0), Modifiers::NONE); + assert!(h.state().ui.floating[0].rect[2] > original[2] + 25.0); + assert!(h.state().ui.floating[0].rect[3] > original[3] + 15.0); + action_log.push(action_state(&h, "resized")); + let grip_id = egui::Id::new(("floating-panel", PanelKind::Info.id())).with("resize"); + assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(grip_id)); + let before_keys = h.state().ui.floating[0].rect; + for key in [egui::Key::ArrowRight, egui::Key::ArrowDown] { + for pressed in [true, false] { + h.event(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }); + step(&mut h); + } + } + assert_eq!(h.state().ui.floating[0].rect[2], before_keys[2] + 8.0); + assert_eq!(h.state().ui.floating[0].rect[3], before_keys[3] + 8.0); + h.event(Event::Key { key: egui::Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + step(&mut h); + h.event(Event::Key { key: egui::Key::Tab, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + for _ in 0..3 { + step(&mut h); + } + assert_ne!(h.ctx.memory(|memory| memory.focused()), Some(grip_id)); + action_log.push(action_state(&h, "keyboard-resized")); + let resized = h.state().ui.floating[0].rect; + let header = rect(&h, "panel.float.0.move"); + let from = pos2(header.right() - 24.0, header.center().y); + drag(&mut h, from, from - vec2(24.0, 18.0), Modifiers::NONE); + assert!(h.state().ui.floating[0].rect[0] < resized[0] - 15.0); + assert!(h.state().ui.floating[0].rect[1] < resized[1] - 10.0); + action_log.push(action_state(&h, "moved")); + let saved_root = h.state().ui.dock.clone(); + let saved_floating = h.state().ui.floating.clone(); + let ctx = h.ctx.clone(); + menus::invoke(h.state_mut(), &ctx, "window.maximizeFrame", json!({})).unwrap(); + for _ in 0..3 { + step(&mut h); + } + assert!(rect(&h, "panel.Info").width() > 1000.0); + action_log.push(action_state(&h, "maximized")); + assert_eq!(h.state().ui.dock, saved_root); + assert_eq!(h.state().ui.floating, saved_floating); + menus::invoke(h.state_mut(), &ctx, "window.maximizeFrame", json!({})).unwrap(); + for _ in 0..3 { + step(&mut h); + } + assert_eq!(h.state().ui.floating, saved_floating); + + if let Some(directory) = &snapshots { + std::fs::create_dir_all(directory).unwrap(); + h.render().unwrap().save(directory.join("filmcraft-floating.png")).unwrap(); + } + let from = rect(&h, "panel.tab.Info").center(); + let to = rect(&h, "panel.History").center(); + drag(&mut h, from, to, Modifiers::NONE); + assert!(h.state().ui.floating.is_empty()); + action_log.push(action_state(&h, "redocked")); + assert!(std::sync::Arc::ptr_eq(&project, &h.state().session.project)); + assert_eq!(playheads, h.state().session.state.playheads); + let playhead = h.state().session.playhead(); + click(&mut h, "program.transport.playhead.stepForward"); + assert!(h.state().session.playhead() > playhead); + assert!(h.state().auto.elements.iter().any(|element| element.id.starts_with("timeline.clip."))); + action_log.push(action_state(&h, "program-step-forward")); + if let Some(directory) = &snapshots { + std::fs::write(directory.join("filmcraft-actions.json"), serde_json::to_vec_pretty(&action_log).unwrap()).unwrap(); + } + if let Some(directory) = &snapshots { + h.render().unwrap().save(directory.join("filmcraft-redocked.png")).unwrap(); + } + assert!(std::sync::Arc::ptr_eq(&project, &h.state().session.project)); + assert!(h.state().ui_error.is_none()); +} + +fn same_group(root: &DockNode, first: PanelKind, second: PanelKind) -> bool { + match root { + DockNode::Tabs { panels, .. } => panels.contains(&first) && panels.contains(&second), + DockNode::Split { a, b, .. } => same_group(a, first, second) || same_group(b, first, second), + } +} + +fn tabs_containing(root: &DockNode, panel: PanelKind) -> Vec { + match root { + DockNode::Tabs { panels, .. } if panels.contains(&panel) => panels.clone(), + DockNode::Split { a, b, .. } => { + let first = tabs_containing(a, panel); + if first.is_empty() { tabs_containing(b, panel) } else { first } + } + _ => vec![], + } +} + +#[test] +fn docking_commands_reject_bad_geometry_and_targets_without_partial_changes() { + let mut app = FilmcraftApp::new(Session::default()); + app.ui.dock = layout().root; + let ctx = egui::Context::default(); + let before = app.ui.dock.clone(); + for (id, params) in [ + ("window.floatPanel", json!({"panel": "Info", "rect": [1, 2, 300]})), + ("window.floatPanel", json!({"panel": "Info", "rect": [1, 2, 300, 200, 7]})), + ("window.floatPanel", json!({"panel": "Info", "rect": [1, 2, 1e100, 200]})), + ("window.dockPanel", json!({"panel": "Info", "anchor": "Tools"})), + ("window.dockPanel", json!({"panel": "Info", "anchor": "Program", "zone": 42})), + ("window.dockPanel", json!({"panel": "Info", "anchor": "Program", "before": "Tools"})), + ] { + assert!(menus::invoke(&mut app, &ctx, id, params.clone()).is_err(), "{id} accepted {params}"); + assert_eq!(app.ui.dock, before); + assert!(app.ui.floating.is_empty()); + } + app.ui.dock = DockNode::Tabs { panels: vec![PanelKind::Project], active: 0 }; + assert!(menus::invoke(&mut app, &ctx, "window.floatPanel", json!({"panel": "Project", "rect": [1, 2, 300, 200]})).is_err()); +} + +fn action_state(h: &Harness<'_, FilmcraftApp>, action: &str) -> serde_json::Value { + json!({"action": action, "dock": h.state().ui.dock, "floating": h.state().ui.floating, "focused": h.state().ui.focused}) +} + +#[test] +fn catalog_docking_action_supports_resize_reorder_and_floating_geometry() { + let mut app = FilmcraftApp::new(Session::default()); + app.ui.dock = DockNode::Split { + vertical: false, + size: SplitSize::FixedA(300.0), + a: Box::new(DockNode::Tabs { panels: vec![PanelKind::Project, PanelKind::Info], active: 1 }), + b: Box::new(DockNode::Tabs { panels: vec![PanelKind::Program], active: 0 }), + }; + let ctx = egui::Context::default(); + for action in [ + Action::ResizeSplit { path: vec![], size: craft_ui::layout::SplitSize::FixedFirst(360.0) }, + Action::Move { panel: PanelKind::Info, anchor: PanelKind::Project, placement: Placement::Tab { before: Some(PanelKind::Project) } }, + Action::Float { panel: PanelKind::Info, rect: [20.0, 40.0, 300.0, 200.0] }, + Action::MoveFloating { panel: PanelKind::Info, rect: [45.0, 65.0, 330.0, 240.0] }, + ] { + filmcraft_ui_egui::menus::invoke(&mut app, &ctx, "window.dockAction", json!({"action": action})).unwrap(); + } + assert_eq!(app.ui.floating[0].rect, [45.0, 65.0, 330.0, 240.0]); + let DockNode::Split { size, .. } = &app.ui.dock else { panic!("split lost") }; + assert_eq!(*size, SplitSize::FixedA(360.0)); + let before = json!({"dock": app.ui.dock, "floating": app.ui.floating}); + assert!( + filmcraft_ui_egui::menus::invoke( + &mut app, + &ctx, + "window.dockAction", + json!({"action": { + "ResizeSplit": {"path": [true, true], "size": {"Ratio": 0.5}} + }}) + ) + .is_err() + ); + assert_eq!(json!({"dock": app.ui.dock, "floating": app.ui.floating}), before); +} + +#[test] +fn unreadable_floating_workspace_does_not_erase_valid_workspaces_or_current_name() { + let root = DockNode::Tabs { panels: vec![PanelKind::Project], active: 0 }; + let valid = json!({"name": "Valid", "layout": root, "floating": [{ + "panels": ["TextToSpeech", "Timeline"], "active": 0, "rect": [80, 90, 420, 320] + }]}); + for (index, bad) in [ + json!([{"panels": ["FuturePanel"], "active": 0, "rect": [80, 90, 420, 320]}]), + json!([{"panels": ["Info"], "active": "invalid", "rect": [80, 90, 420, 320]}]), + json!({"malformed": true}), + ] + .into_iter() + .enumerate() + { + let path = std::env::temp_dir().join(format!("filmcraft-floating-isolation-{}-{index}.json", std::process::id())); + let prefs = json!({"saved": [valid.clone(), {"name": "Broken", "layout": root, "floating": bad}], "current": "Valid"}); + std::fs::write(&path, serde_json::to_vec(&prefs).unwrap()).unwrap(); + let loaded = dock::WorkspacePrefs::load(&path); + assert_eq!(loaded.current, "Valid"); + assert_eq!(loaded.saved.len(), 2); + let restored = dock::saved_docking(&loaded, "Valid"); + restored.validate().unwrap(); + assert_eq!(restored.root, root); + assert_eq!(restored.floating[0].panels, vec![PanelKind::TextToSpeech, PanelKind::Timeline]); + assert_eq!(dock::saved_docking(&loaded, "Broken").root, dock::workspace("Broken")); + loaded.save(&path).unwrap(); + assert_eq!(dock::WorkspacePrefs::load(&path), loaded, "saving preserves the unreadable entry for a future version"); + std::fs::remove_file(path).unwrap(); + } +} + +#[test] +fn floating_tts_and_timeline_reveal_keeps_one_owner_and_draft_without_project_edits() { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).unwrap(); + let mut app = FilmcraftApp::new(session); + app.ui.tts.text = "Synthetic retained narration".into(); + app.show_panel(PanelKind::TextToSpeech); + let project = app.session.project.clone(); + let state = serde_json::to_value(&app.session.state).unwrap(); + let draft = serde_json::to_value(&app.ui.tts).unwrap(); + let ctx = egui::Context::default(); + menus::invoke(&mut app, &ctx, "window.floatPanel", json!({"panel": "TextToSpeech", "rect": [80, 90, 420, 320]})).unwrap(); + menus::invoke( + &mut app, + &ctx, + "window.dockAction", + json!({"action": Action::Move { + panel: PanelKind::Timeline, anchor: PanelKind::TextToSpeech, placement: Placement::Tab { before: None } + }}), + ) + .unwrap(); + app.show_panel(PanelKind::TextToSpeech); + app.show_panel(PanelKind::Timeline); + assert!(!app.ui.dock.contains(PanelKind::TextToSpeech)); + assert!(!app.ui.dock.contains(PanelKind::Timeline)); + assert_eq!(app.ui.floating.iter().flat_map(|group| &group.panels).filter(|panel| **panel == PanelKind::TextToSpeech).count(), 1); + assert_eq!(app.ui.floating.iter().flat_map(|group| &group.panels).filter(|panel| **panel == PanelKind::Timeline).count(), 1); + assert_eq!(serde_json::to_value(&app.ui.tts).unwrap(), draft); + assert!(std::sync::Arc::ptr_eq(&project, &app.session.project)); + assert_eq!(serde_json::to_value(&app.session.state).unwrap(), state); +} + +#[test] +fn narration_selection_reveals_covered_floating_tts_once_without_editing_the_project() { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).unwrap(); + let clip = session + .active_sequence() + .unwrap() + .audio_tracks + .iter() + .flat_map(|track| &track.items) + .find(|clip| { + session.project.item(clip.item).and_then(|item| item.as_media()).is_some_and(|media| media.info.kind == filmcraft_media::MediaKind::AudioOnly) + }) + .unwrap() + .clone(); + let narration = filmcraft_project::Narration { + text: "Synthetic narration behind an overlapping pane".into(), + language: "en-US".into(), + voice: filmcraft_tts::default_voice_id().into(), + pitch: filmcraft_project::VocalPitch::Default, + pace: 1.2, + speech_duration: filmcraft_time::Tick::ZERO, + }; + std::sync::Arc::make_mut(&mut session.project).narrations.insert(clip.item, narration.clone()); + let project = session.project.clone(); + let mut app = FilmcraftApp::new(session); + app.show_panel(PanelKind::TextToSpeech); + let mut h = Harness::builder().with_size(vec2(1200.0, 760.0)).build_eframe(move |_| app); + for _ in 0..4 { + step(&mut h); + } + let ctx = h.ctx.clone(); + for panel in ["TextToSpeech", "Info"] { + menus::invoke(h.state_mut(), &ctx, "window.floatPanel", json!({"panel": panel, "rect": [400, 220, 450, 360]})).unwrap(); + for _ in 0..4 { + step(&mut h); + } + } + let layer = |panel: PanelKind| egui::LayerId::new(egui::Order::Middle, egui::Id::new(("floating-panel", panel.id()))); + let covered = rect(&h, "panel.TextToSpeech").center(); + assert!(rect(&h, "panel.Info").contains(covered)); + ctx.move_to_top(layer(PanelKind::Info)); + for _ in 0..3 { + step(&mut h); + } + assert_eq!(ctx.layer_id_at(covered), Some(layer(PanelKind::Info)), "precondition: Info really covers TTS"); + h.state_mut().session.state.selection = vec![clip.id]; + for _ in 0..4 { + step(&mut h); + } + let draft = h.state().ui.tts.clone(); + assert_eq!(draft.text, narration.text); + assert_eq!(draft.pace, narration.pace); + assert_eq!(draft.loaded_from, Some((clip.id.0, clip.item.0))); + assert_eq!(ctx.layer_id_at(covered), Some(layer(PanelKind::TextToSpeech)), "narration selection must bring its floating editor forward"); + assert_eq!(h.state().ui.focused, PanelKind::TextToSpeech); + assert!(!h.state().ui.dock.contains(PanelKind::TextToSpeech)); + assert_eq!(h.state().ui.floating.iter().flat_map(|group| &group.panels).filter(|panel| **panel == PanelKind::TextToSpeech).count(), 1); + assert!(std::sync::Arc::ptr_eq(&project, &h.state().session.project)); + // A reveal is one-shot: drawing the selected narration must not steal stacking + // from another pane that the user subsequently brings forward. + ctx.move_to_top(layer(PanelKind::Info)); + for _ in 0..4 { + step(&mut h); + } + assert_eq!(ctx.layer_id_at(covered), Some(layer(PanelKind::Info))); + assert_eq!(h.state().ui.tts, draft); + menus::invoke(h.state_mut(), &ctx, "window.panel.TextToSpeech", json!({})).unwrap(); + for _ in 0..4 { + step(&mut h); + } + assert_eq!(ctx.layer_id_at(covered), Some(layer(PanelKind::TextToSpeech))); + assert_eq!(h.state().ui.tts, draft); + assert!(std::sync::Arc::ptr_eq(&project, &h.state().session.project)); + // Replacing the workspace cancels a queued reveal before the next render, + // even when the destination happens to contain the same floating panel IDs. + ctx.move_to_top(layer(PanelKind::Info)); + for _ in 0..3 { + step(&mut h); + } + let saved = dock::SavedWorkspace { + name: "Reveal cancellation".into(), + layout: serde_json::to_value(&h.state().ui.dock).unwrap(), + floating: serde_json::to_value(&h.state().ui.floating).unwrap(), + }; + h.state_mut().workspaces.saved.push(saved); + h.state_mut().show_panel(PanelKind::TextToSpeech); + h.state_mut().set_workspace("Reveal cancellation"); + for _ in 0..4 { + step(&mut h); + } + assert_eq!(ctx.layer_id_at(covered), Some(layer(PanelKind::Info))); + assert_eq!(h.state().ui.tts, draft); + assert!(std::sync::Arc::ptr_eq(&project, &h.state().session.project)); +} diff --git a/crates/ui-egui/tests/timecode_ui.rs b/crates/ui-egui/tests/timecode_ui.rs index badc35365..d0b849284 100644 --- a/crates/ui-egui/tests/timecode_ui.rs +++ b/crates/ui-egui/tests/timecode_ui.rs @@ -174,3 +174,37 @@ fn the_program_and_source_timecodes_move_their_own_playheads() { assert_eq!(d.source_frame(), parse_timecode("100", d.source_rate(), false, 0).unwrap(), "1 s at the clip's own rate"); assert_eq!(d.harness.state().session.playhead(), program); } + +#[test] +fn compact_floating_monitor_timecodes_keep_drag_typing_escape_and_caret_routing() { + for scale in [1.0, 2.0] { + let mut d = Driver::demo(); + d.harness.ctx.set_pixels_per_point(scale); + d.frames(4); + for (panel, x) in [("Source", 70), ("Program", 360)] { + d.ok("engine.execute", json!({"command": "window.floatPanel", "params": {"panel": panel, "rect": [x, 100, 250, 360]}})); + } + let project = d.harness.state().session.project.clone(); + d.set_frame(30); + d.drag_by("program.timecode", 20.0); + assert_eq!(d.frame(), 50); + let program = d.harness.state().session.playhead(); + let source = d.source_frame(); + d.drag_by("source.timecode", 20.0); + assert_eq!(d.source_frame(), source + 20); + assert_eq!(d.harness.state().session.playhead(), program); + d.type_into("program.timecode", "1000", "Escape"); + assert_eq!(d.harness.state().session.playhead(), program); + d.type_into("source.timecode", "100", "Enter"); + assert_eq!(d.source_frame(), parse_timecode("100", d.source_rate(), false, 0).unwrap()); + assert_eq!(d.harness.state().session.playhead(), program); + d.ok("ui.click", json!({"id": "program.timecode"})); + d.ok("ui.key", json!({"key": "Left"})); + assert_eq!(d.harness.state().session.playhead(), program, "caret arrows must not seek"); + d.ok("ui.key", json!({"key": "Escape"})); + d.type_into("program.timecode", "200", "Enter"); + let s = &d.harness.state().session; + assert_eq!(d.frame(), parse_timecode("200", s.sequence_rate(), s.active_sequence().unwrap().settings.drop_frame, 0).unwrap()); + assert!(std::sync::Arc::ptr_eq(&project, &s.project)); + } +} diff --git a/crates/ui-egui/tests/timeline_drop_ui.rs b/crates/ui-egui/tests/timeline_drop_ui.rs index 76bdde9fa..514b2a03d 100644 --- a/crates/ui-egui/tests/timeline_drop_ui.rs +++ b/crates/ui-egui/tests/timeline_drop_ui.rs @@ -152,3 +152,117 @@ fn with_drop_import_off_the_file_is_only_imported() { assert_eq!(h.state().session.project.items.len(), items_before + 1, "the still is imported to the bin"); assert_eq!(clip_counts(&h), (0, 0), "status: {}", h.state().ui.status); } + +fn float_panel(h: &mut Harness<'static, FilmcraftApp>, panel: &str, rect: [f32; 4]) { + let ctx = h.ctx.clone(); + filmcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "window.floatPanel", json!({"panel": panel, "rect": rect})).unwrap(); + for _ in 0..4 { + step(h); + } +} + +fn drop_at(h: &mut Harness<'static, FilmcraftApp>, pos: egui::Pos2, path: &Path) { + pointer_to(h, pos); + h.input_mut().dropped_files.push(std::sync::Arc::new(OsFile(path.to_path_buf()))); + step(h); + h.input_mut().dropped_files.clear(); + step(h); +} + +fn hover_at(h: &mut Harness<'static, FilmcraftApp>, pos: egui::Pos2) -> bool { + pointer_to(h, pos); + h.input_mut().hovered_files.push(Default::default()); + step(h); + let preview = h.state().auto.elements.iter().any(|element| element.id == "timeline.fileDropPreview"); + h.input_mut().hovered_files.clear(); + step(h); + preview +} + +#[test] +fn floating_panel_blocks_os_drop_and_hover_but_uncovered_timeline_still_accepts() { + for scale in [1.0, 2.0] { + let mut h = harness(); + h.ctx.set_pixels_per_point(scale); + for _ in 0..4 { + step(&mut h); + } + let at = point_on_row(&h, "timeline.track.V1.target"); + float_panel(&mut h, "Info", [at.x - 20.0, at.y - 60.0, 320.0, 200.0]); + let project = h.state().session.project.clone(); + let covered_preview = hover_at(&mut h, at); + assert!(std::sync::Arc::ptr_eq(&project, &h.state().session.project), "hover does not edit the project"); + let items_before = h.state().session.project.items.len(); + drop_at(&mut h, at, &still(&format!("drop_covered_{scale}.png"))); + assert_eq!(h.state().session.project.items.len(), items_before + 1); + assert_eq!(clip_counts(&h), (0, 0), "a covered Timeline must not be edited"); + assert!(!covered_preview, "a covered Timeline must not preview a placement"); + float_panel(&mut h, "Info", [5.0, 5.0, 320.0, 200.0]); + let at = point_on_row(&h, "timeline.track.V1.target"); + assert!(hover_at(&mut h, at)); + drop_at(&mut h, at, &still(&format!("drop_uncovered_{scale}.png"))); + assert_eq!(clip_counts(&h), (1, 0)); + } +} + +#[test] +fn floating_timeline_accepts_once_and_another_float_can_occlude_it() { + let mut h = harness(); + float_panel(&mut h, "Timeline", [300.0, 180.0, 950.0, 640.0]); + let at = point_on_row(&h, "timeline.track.V1.target"); + assert!(hover_at(&mut h, at)); + drop_at(&mut h, at, &still("drop_floating_timeline.png")); + assert_eq!(clip_counts(&h), (1, 0)); + float_panel(&mut h, "Info", [at.x - 20.0, at.y - 60.0, 320.0, 200.0]); + assert!(!hover_at(&mut h, at)); + drop_at(&mut h, at, &still("drop_occluded_floating_timeline.png")); + assert_eq!(clip_counts(&h), (1, 0)); +} + +#[test] +fn offscreen_float_uses_rendered_clamp_and_maximized_timeline_ignores_hidden_floats() { + let mut h = harness(); + let at = point_on_row(&h, "timeline.track.V1.target"); + // The very wide offscreen float is clamped back to x=0, over this row. + float_panel(&mut h, "Info", [-5000.0, at.y - 60.0, 1500.0, 200.0]); + let visible = control_rect(&h, "panel.Info"); + assert!(visible.contains(at), "rendered={visible:?}, drop={at:?}, floats={:?}", h.state().ui.floating); + assert!(!hover_at(&mut h, at)); + drop_at(&mut h, at, &still("drop_clamped_occlusion.png")); + assert_eq!(clip_counts(&h), (0, 0)); + h.state_mut().ui.keys.maximized = Some(filmcraft_ui_egui::dock::PanelKind::Timeline); + for _ in 0..4 { + step(&mut h); + } + let at = point_on_row(&h, "timeline.track.V1.target"); + assert!(hover_at(&mut h, at)); + drop_at(&mut h, at, &still("drop_maximized_timeline.png")); + assert_eq!(clip_counts(&h), (1, 0)); +} + +#[test] +fn os_cursor_hook_selects_destination_when_egui_pointer_has_no_motion() { + let mut h = harness(); + let at = point_on_row(&h, "timeline.track.V1.target"); + float_panel(&mut h, "Info", [at.x - 20.0, at.y - 60.0, 320.0, 200.0]); + // Physical pixels at 1x, with the synthetic viewport origin fixed at zero. + h.state_mut().hooks.cursor_screen_position = Some(Box::new(move || Some((at.x.round() as i32, at.y.round() as i32)))); + if let Some(viewport) = h.input_mut().viewports.get_mut(&egui::ViewportId::ROOT) { + viewport.inner_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 980.0))); + } + h.input_mut().hovered_files.push(Default::default()); + step(&mut h); + assert!(!h.state().auto.elements.iter().any(|element| element.id == "timeline.fileDropPreview")); + h.input_mut().hovered_files.clear(); + h.input_mut().dropped_files.push(std::sync::Arc::new(OsFile(still("drop_os_hook_covered.png")))); + step(&mut h); + h.input_mut().dropped_files.clear(); + step(&mut h); + assert_eq!(clip_counts(&h), (0, 0)); + h.state_mut().ui.floating.clear(); + h.input_mut().dropped_files.push(std::sync::Arc::new(OsFile(still("drop_os_hook_visible.png")))); + step(&mut h); + h.input_mut().dropped_files.clear(); + step(&mut h); + assert_eq!(clip_counts(&h), (1, 0)); +} diff --git a/crates/ui-egui/tests/ui_control_input.rs b/crates/ui-egui/tests/ui_control_input.rs new file mode 100644 index 000000000..773b451dd --- /dev/null +++ b/crates/ui-egui/tests/ui_control_input.rs @@ -0,0 +1,277 @@ +//! Control-only validation must not share keyboard ownership with native computer use. + +use std::sync::{ + Arc, + mpsc::{Sender, channel}, +}; + +use egui::{Event, Id, Key, Modifiers, vec2}; +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_ui_egui::{ + FilmcraftApp, + control::ControlRequest, + dock::{DockNode, PanelKind, SplitSize}, +}; +use serde_json::{Value, json}; + +struct Driver { + h: Harness<'static, FilmcraftApp>, + tx: Sender, +} + +impl Driver { + fn new(isolated: bool) -> Self { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).unwrap(); + let (tx, rx) = channel(); + let app = FilmcraftApp::new(session); + let app = if isolated { app.with_control_input_only(rx) } else { app.with_control(rx) }; + let mut driver = Self { h: Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(move |_| app), tx }; + driver.frames(4, true); + driver + } + + fn frames(&mut self, count: usize, native_focused: bool) { + for _ in 0..count { + let ctx = self.h.ctx.clone(); + let mut raw = std::mem::take(self.h.input_mut()); + raw.focused = native_focused; + if let Some(viewport) = raw.viewports.get_mut(&egui::ViewportId::ROOT) { + viewport.focused = Some(native_focused); + } + eframe::App::raw_input_hook(self.h.state_mut(), &ctx, &mut raw); + *self.h.input_mut() = raw; + self.h.step(); + assert!(self.h.state().ui_error.is_none()); + } + } + + fn ok(&mut self, method: &str, params: Value, native_focused: bool) -> Value { + let (request, reply) = ControlRequest::new(method, params); + self.tx.send(request).unwrap(); + for _ in 0..60 { + self.frames(1, native_focused); + if let Ok(result) = reply.try_recv() { + assert_eq!(result["ok"], true, "{result}"); + self.frames(2, native_focused); + return result["result"].clone(); + } + } + panic!("no control reply to {method}"); + } +} + +fn native_key(h: &mut Harness<'_, FilmcraftApp>, key: Key) { + for pressed in [true, false] { + // The backend supplies native events before raw_input_hook. Harness::event would + // instead append them after that hook and bypass the input boundary under test. + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }); + } +} + +#[test] +fn native_j_cannot_start_reverse_playback_during_control_only_editing() { + let mut d = Driver::new(true); + d.ok("engine.execute", json!({"command":"playhead.set","params":{"seconds":1}}), true); + let before = d.ok("engine.execute", json!({"command":"sequence.inspect","params":{}}), true); + let (request, reply) = ControlRequest::new("ui.key", json!({"key":"K","command":true})); + d.tx.send(request).unwrap(); + d.frames(1, true); // queue the requested command; the native key arrives in its input frame + native_key(&mut d.h, Key::J); + d.frames(4, true); + assert_eq!(reply.try_recv().unwrap()["ok"], true); + assert!(!d.h.state().playback.playing, "native J must not contaminate the automated edit"); + let after = d.ok("engine.execute", json!({"command":"sequence.inspect","params":{}}), true); + assert_eq!(after["playhead"], before["playhead"]); + assert_eq!(after["video"][0]["items"].as_array().unwrap().len(), before["video"][0]["items"].as_array().unwrap().len() + 1); +} + +#[test] +fn background_control_input_resizes_the_focused_divider_without_seeking() { + background_divider(true); +} + +#[test] +fn ordinary_control_input_also_resizes_a_background_divider() { + background_divider(false); +} + +fn background_divider(isolated: bool) { + let mut d = Driver::new(isolated); + d.h.state_mut().ui.dock = DockNode::Split { + vertical: false, + size: SplitSize::FixedA(400.0), + a: Box::new(DockNode::Tabs { panels: vec![PanelKind::Project], active: 0 }), + b: Box::new(DockNode::Tabs { panels: vec![PanelKind::Timeline], active: 0 }), + }; + d.frames(3, true); + let before_project = d.h.state().session.project.clone(); + let before_playheads = d.h.state().session.state.playheads.clone(); + let divider = Id::new(("dock-gutter", String::new())); + d.h.ctx.memory_mut(|memory| memory.request_focus(divider)); + d.frames(2, true); + d.h.input_mut().events.push(Event::WindowFocused(false)); + d.ok("ui.key", json!({"key":"ArrowRight"}), false); + assert!(matches!(d.h.state().ui.dock, DockNode::Split { size: SplitSize::FixedA(408.0), .. }), "control input must work without native activation"); + assert_eq!(d.h.state().session.state.playheads, before_playheads); + assert!(Arc::ptr_eq(&d.h.state().session.project, &before_project)); + assert!(!d.h.state().playback.playing); +} + +#[test] +fn interactive_control_keeps_native_keyboard_and_menu_input() { + let mut d = Driver::new(false); + native_key(&mut d.h, Key::J); + d.frames(2, true); + assert!(d.h.state().playback.playing); + assert_eq!(d.h.state().playback.speed, -1.0); + d.ok("ui.playback", json!({"action":"stop"}), true); + let (tx, rx) = channel(); + d.h.state_mut().command_inbox = Some(rx); + tx.send("playback.reverse".into()).unwrap(); + d.frames(2, true); + assert!(d.h.state().playback.playing); + assert_eq!(d.h.state().playback.speed, -1.0); +} + +#[test] +fn native_menu_commands_cannot_contaminate_control_only_validation() { + let mut d = Driver::new(true); + let (tx, rx) = channel(); + d.h.state_mut().command_inbox = Some(rx); + tx.send("playback.reverse".into()).unwrap(); + d.frames(2, true); + assert!(!d.h.state().playback.playing); + // Intentional playback remains reachable through the control command path. + d.ok("engine.execute", json!({"command":"playback.reverse","params":{}}), false); + assert!(d.h.state().playback.playing); + assert_eq!(d.h.state().playback.speed, -1.0); +} + +#[test] +fn isolation_preserves_close_and_screenshot_delivery_without_claiming_os_focus() { + let mut d = Driver::new(true); + let mut raw = egui::RawInput { focused: false, ..Default::default() }; + let viewport = raw.viewports.get_mut(&egui::ViewportId::ROOT).unwrap(); + viewport.focused = Some(false); + viewport.events.push(egui::ViewportEvent::Close); + raw.events = vec![ + Event::Text("unrelated typing".into()), + Event::WindowFocused(false), + Event::Screenshot { + viewport_id: egui::ViewportId::ROOT, + user_data: Default::default(), + image: Arc::new(egui::ColorImage::new([1, 1], vec![egui::Color32::WHITE])), + }, + ]; + raw.hovered_files.push(Default::default()); + #[derive(Debug)] + struct UnrelatedFile; + impl egui::DroppedFile for UnrelatedFile { + fn path(&self) -> &std::path::Path { + std::path::Path::new("/unrelated-input.txt") + } + #[cfg(not(target_arch = "wasm32"))] + fn bytes(&self) -> Result, String> { + Err("must not read unrelated native input".into()) + } + #[cfg(target_arch = "wasm32")] + fn bytes_async(&self) -> std::pin::Pin, String>> + '_>> { + Box::pin(async { Err("must not read unrelated native input".into()) }) + } + } + raw.dropped_files.push(Arc::new(UnrelatedFile)); + let ctx = d.h.ctx.clone(); + eframe::App::raw_input_hook(d.h.state_mut(), &ctx, &mut raw); + assert!(raw.focused); + assert_eq!(raw.viewports[&egui::ViewportId::ROOT].focused, Some(false)); + assert_eq!(raw.viewports[&egui::ViewportId::ROOT].events, vec![egui::ViewportEvent::Close]); + assert!(matches!(raw.events.as_slice(), [Event::Screenshot { .. }, Event::ModifiersChanged(Modifiers::NONE)])); + assert!(raw.hovered_files.is_empty() && raw.dropped_files.is_empty()); + let state = filmcraft_ui_egui::control::inspect(d.h.state(), &ctx); + assert_eq!(state["input"]["nativeWindowFocused"], false); +} + +#[test] +fn focus_reports_real_widget_ownership_and_rejects_bad_targets_without_changing_it() { + let mut d = Driver::new(true); + let focus = d.ok("ui.focus", json!({"id":"tools.Selection"}), false); + assert_eq!(focus["focusRequested"], "tools.Selection"); + let state = d.ok("ui.inspect", json!({}), false); + assert_eq!(state["keyboardFocus"]["widgetId"], focus["widgetId"]); + assert_eq!(state["keyboardFocus"]["elementIds"], json!(["tools.Selection"])); + assert_eq!(state["keyboardFocus"]["active"], true); + assert_eq!(state["input"]["nativeWindowFocused"], false); + assert_eq!(state["input"]["effectiveWindowFocused"], true); + let focused = d.h.ctx.memory(|memory| memory.focused()); + for params in [ + json!({"id":7}), + json!({"id":"missing"}), + json!({"id":"panel.Tools"}), + json!({"id":"tools.Rolling"}), + json!({"id":"tools.Selection","activate":true}), + json!([]), + ] { + let (request, _) = ControlRequest::new("ui.focus", params); + let ctx = d.h.ctx.clone(); + let filmcraft_ui_egui::control::Outcome::Done(result) = filmcraft_ui_egui::control::handle(d.h.state_mut(), &ctx, &request) else { + panic!("bad focus target must fail immediately"); + }; + assert_eq!(result["ok"], false, "{result}"); + assert_eq!(d.h.ctx.memory(|memory| memory.focused()), focused); + } + d.ok("ui.key", json!({"key":"Tab"}), false); + let state = d.ok("ui.inspect", json!({}), false); + assert_eq!(state["keyboardFocus"]["elementIds"], json!(["tools.TrackSelectForward"])); + assert_ne!(state["keyboardFocus"]["widgetId"], focus["widgetId"]); + assert!(!d.h.state().playback.playing); +} + +#[test] +fn modified_control_click_adds_to_timeline_selection() { + for isolated in [true, false] { + let mut d = Driver::new(isolated); + d.ok("ui.set", json!({"timeline":{"fit":true}}), false); + d.frames(30, false); + let seq = d.h.state().session.active_sequence().unwrap(); + let first = seq.video_tracks[0].items[0].id; + let second = seq.video_tracks[0].items[1].id; + let project = d.h.state().session.project.clone(); + d.ok("engine.execute", json!({"command":"timeline.select","params":{"clips":[first.0]}}), false); + let position = d.ok("ui.timeline.locate", json!({"clip":second.0}), false); + d.ok("ui.click", json!({"x":position["x"],"y":position["y"],"shift":true}), false); + assert!(d.h.state().session.state.selection.contains(&first), "Shift-click must retain the first selected clip (isolated={isolated})"); + assert!(d.h.state().session.state.selection.contains(&second)); + assert!(Arc::ptr_eq(&d.h.state().session.project, &project)); + assert!(!d.h.state().playback.playing); + assert_eq!(d.h.ctx.input(|input| input.modifiers), Modifiers::NONE); + } +} + +#[test] +fn modified_control_drag_keeps_shift_until_release_and_clears_it_afterward() { + let mut d = Driver::new(true); + let (request, reply) = ControlRequest::new( + "ui.drag", + json!({ + "from":{"id":"dock.gutter."},"to":{"id":"dock.gutter.","fy":3.0},"steps":4,"shift":true, + }), + ); + d.tx.send(request).unwrap(); + let mut held_frames = 0; + for _ in 0..60 { + d.frames(1, false); + if d.h.ctx.input(|input| input.pointer.any_down() || input.pointer.any_released()) { + held_frames += 1; + assert!(d.h.ctx.input(|input| input.modifiers.shift), "the synthetic gesture owns Shift throughout press, movement and release"); + } + if let Ok(result) = reply.try_recv() { + assert_eq!(result["ok"], true); + break; + } + } + assert!(held_frames >= 4); + d.frames(3, false); + assert_eq!(d.h.ctx.input(|input| input.modifiers), Modifiers::NONE); +} diff --git a/crates/ui-egui/tests/ui_dock_resize.rs b/crates/ui-egui/tests/ui_dock_resize.rs new file mode 100644 index 000000000..12129c957 --- /dev/null +++ b/crates/ui-egui/tests/ui_dock_resize.rs @@ -0,0 +1,95 @@ +//! Shared pane resizing in the full application, including application shortcut ownership. + +use egui::{Event, Id, Key, Modifiers, PointerButton, Rect, pos2, vec2}; +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_ui_egui::FilmcraftApp; +use filmcraft_ui_egui::dock::{DockNode, PanelKind, SplitSize}; +use filmcraft_ui_egui::theme::ThemeKind; +use serde_json::json; + +fn step(h: &mut Harness<'_, FilmcraftApp>) { + h.step(); + assert!(h.state().ui_error.is_none(), "the app caught a UI panic: {:?}", h.state().ui_error); +} + +fn frames(h: &mut Harness<'_, FilmcraftApp>, count: usize) { + for _ in 0..count { + step(h); + } +} + +fn snapshots(h: &mut Harness<'_, FilmcraftApp>, directory: Option<&std::path::Path>, phase: &str) { + let Some(directory) = directory else { return }; + std::fs::create_dir_all(directory).unwrap(); + for (name, theme) in [("dark", ThemeKind::Dark), ("medium", ThemeKind::Medium), ("light", ThemeKind::Light)] { + let ctx = h.ctx.clone(); + h.state_mut().set_theme(&ctx, theme); + frames(h, 3); + h.render().unwrap().save(directory.join(format!("filmcraft-{phase}-{name}.png"))).unwrap(); + } + let ctx = h.ctx.clone(); + h.state_mut().set_theme(&ctx, ThemeKind::Dark); + frames(h, 3); +} + +fn first_size(h: &Harness<'_, FilmcraftApp>) -> f32 { + match h.state().ui.dock { + DockNode::Split { size: SplitSize::FixedA(value), .. } => value, + ref other => panic!("fixed-size mode changed: {other:?}"), + } +} + +#[test] +fn focused_dock_resize_owns_arrows_and_keeps_pointer_dragging() { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).unwrap(); + let app = FilmcraftApp::new(session); + // Opt in to GPU snapshots while the ordinary test remains headless and renderer-independent. + let directory = std::env::var_os("CRAFT_UI_PANES_SNAPSHOT_DIR").map(std::path::PathBuf::from); + let mut builder = Harness::builder().with_size(vec2(1400.0, 900.0)).with_max_steps(10_000); + if directory.is_some() { + builder = builder.wgpu(); + } + let mut h = builder.build_eframe(move |_| app); + frames(&mut h, 4); + snapshots(&mut h, directory.as_deref(), "workspace"); + h.state_mut().ui.focused = PanelKind::Timeline; + h.state_mut().ui.dock = DockNode::Split { + vertical: false, + size: SplitSize::FixedA(400.0), + a: Box::new(DockNode::Tabs { panels: vec![PanelKind::Project], active: 0 }), + b: Box::new(DockNode::Tabs { panels: vec![PanelKind::Timeline], active: 0 }), + }; + frames(&mut h, 3); + let before_project = h.state().session.project.clone(); + let before_playheads = h.state().session.state.playheads.clone(); + let id = Id::new(("dock-gutter", String::new())); + h.ctx.memory_mut(|memory| memory.request_focus(id)); + frames(&mut h, 2); + assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(id)); + snapshots(&mut h, directory.as_deref(), "focused-resize"); + h.event(Event::Key { key: Key::ArrowRight, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + step(&mut h); + h.event(Event::Key { key: Key::ArrowRight, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + step(&mut h); + assert_eq!(first_size(&h), 408.0); + assert_eq!(h.state().session.state.playheads, before_playheads, "the Timeline shortcut must not seek"); + assert!(std::sync::Arc::ptr_eq(&h.state().session.project, &before_project)); + + frames(&mut h, 2); + let element = h.state().auto.find("dock.gutter.").unwrap(); + let from = Rect::from_min_size(pos2(element.rect[0], element.rect[1]), vec2(element.rect[2], element.rect[3])).center(); + h.event(Event::PointerMoved(from)); + step(&mut h); + h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + step(&mut h); + for offset in [5.0, 10.0, 15.0, 20.0] { + h.event(Event::PointerMoved(from + vec2(offset, 0.0))); + step(&mut h); + } + h.event(Event::PointerButton { pos: from + vec2(20.0, 0.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + step(&mut h); + assert_eq!(first_size(&h), 428.0); + assert_eq!(h.state().session.state.playheads, before_playheads); +} diff --git a/crates/ui-egui/tests/ui_keyboard_focus.rs b/crates/ui-egui/tests/ui_keyboard_focus.rs new file mode 100644 index 000000000..8685d4933 --- /dev/null +++ b/crates/ui-egui/tests/ui_keyboard_focus.rs @@ -0,0 +1,107 @@ +//! Keyboard traversal reaches actual controls and preserves document/playback state. + +use std::sync::Arc; + +use egui::{Event, Id, Key, Modifiers, vec2}; +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_ui_egui::{ + FilmcraftApp, + dock::{DockNode, PanelKind}, + state::Tool, +}; +use serde_json::json; + +fn key(h: &mut Harness<'_, FilmcraftApp>, key: Key, modifiers: Modifiers) { + for pressed in [true, false] { + h.event(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }); + h.step(); + } + h.run_steps(2); + assert!(h.state().ui_error.is_none()); +} + +#[test] +fn tab_traversal_and_space_select_only_the_focused_tool_with_visible_focus() { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).unwrap(); + let project = session.project.clone(); + let playheads = session.state.playheads.clone(); + let app = FilmcraftApp::new(session); + let mut h = Harness::builder().with_size(vec2(1200.0, 800.0)).build_eframe(move |_| app); + h.run_steps(4); + h.state_mut().ui.dock = DockNode::Tabs { panels: vec![PanelKind::Tools], active: 0 }; + h.run_steps(3); + let first = Id::new(("tool", "Selection".to_string())); + let second = Id::new(("tool", "TrackSelectForward".to_string())); + let third = Id::new(("tool", "Ripple".to_string())); + for _ in 0..128 { + key(&mut h, Key::Tab, Modifiers::NONE); + if h.ctx.memory(|m| m.focused()) == Some(first) { + break; + } + } + assert_eq!(h.ctx.memory(|m| m.focused()), Some(first), "Tab must reach the first tool"); + key(&mut h, Key::Tab, Modifiers::NONE); + assert_eq!(h.ctx.memory(|m| m.focused()), Some(second)); + key(&mut h, Key::Tab, Modifiers::NONE); + assert_eq!(h.ctx.memory(|m| m.focused()), Some(third)); + key(&mut h, Key::Tab, Modifiers::SHIFT); + assert_eq!(h.ctx.memory(|m| m.focused()), Some(second)); + assert_eq!(h.state().ui.tool, Tool::Selection, "traversal does not activate controls"); + key(&mut h, Key::Space, Modifiers::NONE); + assert_eq!(h.state().ui.tool, Tool::TrackSelectForward); + assert!(!h.state().playback.playing, "Space belongs to the focused tool"); + assert_eq!(h.state().session.state.playheads, playheads); + assert!(Arc::ptr_eq(&h.state().session.project, &project)); + let response = h.ctx.read_response(second).unwrap(); + assert!( + h.output().shapes.iter().any(|shape| matches!(&shape.shape, + egui::Shape::Rect(r) if r.rect == response.rect.shrink(2.0) + && r.stroke.color == h.state().tokens.focus && r.stroke.width > 0.0)), + "the focused custom-painted tool needs a visible focus outline" + ); +} + +#[test] +fn dock_tab_traversal_and_enter_change_the_visible_panel_without_editing() { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).unwrap(); + let project = session.project.clone(); + let playheads = session.state.playheads.clone(); + let app = FilmcraftApp::new(session); + let mut h = Harness::builder().with_size(vec2(1200.0, 800.0)).build_eframe(move |_| app); + h.run_steps(4); + h.state_mut().ui.show_header = false; + h.state_mut().ui.show_status_bar = false; + h.state_mut().ui.dock = DockNode::Tabs { panels: vec![PanelKind::Project, PanelKind::Effects], active: 0 }; + h.run_steps(3); + let first = Id::new(("tab", String::new(), 0usize)); + let second = Id::new(("tab", String::new(), 1usize)); + for _ in 0..128 { + key(&mut h, Key::Tab, Modifiers::NONE); + if h.ctx.memory(|m| m.focused()) == Some(second) { + break; + } + } + assert_eq!(h.ctx.memory(|m| m.focused()), Some(second)); + assert!(matches!(h.state().ui.dock, DockNode::Tabs { active: 0, .. })); + let response = h.ctx.read_response(second).unwrap(); + assert!( + h.output().shapes.iter().any(|shape| matches!(&shape.shape, + egui::Shape::Rect(r) if r.rect == response.rect.shrink(2.0) + && r.stroke.color == h.state().tokens.focus && r.stroke.width > 0.0)), + "the focused dock tab needs a visible focus outline" + ); + key(&mut h, Key::Space, Modifiers::NONE); + assert!(matches!(h.state().ui.dock, DockNode::Tabs { active: 1, .. })); + assert_eq!(h.state().ui.focused, PanelKind::Effects); + key(&mut h, Key::Tab, Modifiers::SHIFT); + assert_eq!(h.ctx.memory(|m| m.focused()), Some(first)); + key(&mut h, Key::Enter, Modifiers::NONE); + assert!(matches!(h.state().ui.dock, DockNode::Tabs { active: 0, .. })); + assert_eq!(h.state().ui.focused, PanelKind::Project); + assert!(Arc::ptr_eq(&h.state().session.project, &project)); + assert_eq!(h.state().session.state.playheads, playheads); + assert!(!h.state().playback.playing); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 9cf58cf66..16946c0a7 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -38,7 +38,8 @@ 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`, `app.quit` | `{width,height}` / `{}` | resize or close the native window | +| `ui.focus` | `{}` / `{id}` | empty parameters activate the native window; a widget ID requests egui focus without OS activation | | `perf.stats` | – | performance counters (also the command `perf.stats`, so `engine.execute` and MCP `command_run` reach it; headless sessions return the engine part). `decode`: GOP-cache requests / hits / `cacheHitRate`, decoder seeks, samples decoded and skipped while catching up, `draftFrames` (decoded in draft mode), `h264Threads` (frame-threading workers per H.264 decoder), `liveDecoders` (sources holding a decoder now), `cacheMB` / `cacheBudgetMB` (decoded frames all sources hold, and the shared budget), `planePoolMB` / `planesReused` (idle buffers of the frame pool, the planes of evicted frames and the compositor's float images, which a standalone export frees when it ends; and buffers taken from it instead of allocated), decoder ms (total and per sample), `framesDecoded`, `hardware` (Settings ▸ Playback ▸ Hardware decoding: `enabled`, pictures from hardware decoders `frames` vs `softwareFrames`, `sessions`, `declined` streams, mid-stream `fallbacks`); `playback`: playing, shown / dropped frames and `dropRate` of the current or last play, resolution, `draftDecode` (Settings ▸ Playback ▸ Draft decoding), `audio` (desktop: sound is mixed 200 ms ahead of the device; device `callbacks`, `underruns` and `missingFrames` played as silence because the mixer fell behind, `minLeadMs` the least sound left buffered); `frames`: frame-worker jobs, cancelled, `requestHitRate`, `decodeMs` / `renderMs` (mean, p50, p95 of the last 256 jobs), queue, render-cost estimate, cache use; `monitor`: the `frame` and `revision` of the picture the Program monitor shows against `playheadFrame` and `projectRevision` (how far a drag or a scrub is ahead of the picture); `ui`: fps and frame ms; `process`: CPU seconds; `media`, `jobs`. Counters are cumulative: diff two readings to measure an interval ([performance.md](performance.md)) | **Selection or explicit targets.** A command that acts on the selection is "not available right now" when @@ -50,7 +51,22 @@ still apply. `engine.commands` and the menus always show the selection's enablem **Focus.** Driving the app never steals the user's keyboard. Started with `--control`, the app opens without activating itself. UI requests and screenshots that need a rendered frame bring the window forward without making it key (macOS `orderFrontRegardless`), so the user's typing keeps going to -the app they are in. Only an explicit `ui.focus` activates FilmCraft. +the app they are in. Only an explicit `ui.focus {}` activates FilmCraft. Injected keys work on +focused widgets even when the native window is in the background. + +`ui.focus {id}` targets an enabled, visible widget registered with a real egui response. It rejects +unknown IDs, non-widget regions and hidden tool aliases. The reply acknowledges the request; +`ui.inspect.keyboardFocus` reports the actual widget ID, matching element IDs, label and active +focus. This is separate from `ui.focused`, which identifies the active panel. Tools, dock tabs, +tab menus and divider handles expose widget IDs through `ui.elements`. Tab/Shift-Tab move widget +focus; Space/Enter activate focused buttons and tabs, with visible focus outlines. + +For unattended validation, start with `--control --control-input-only`. This opt-in mode +accepts widget input through control requests and excludes native keyboard, pointer, file-drop +and menu commands. Window close/quit and screenshots remain available. Ordinary launches and +`--control` alone keep native input enabled. `ui.inspect.input` distinguishes `mode`, the observed +`nativeWindowFocused`, and egui's `effectiveWindowFocused`; effective focus does not activate the +OS window or prove physical-keyboard delivery. Element ids are stable, e.g. `timeline.clip.`, `timeline.track.V1.lock`, `tools.Razor`, `project.item.`, `effects.item.gaussian_blur`, `panel.tab.Timeline`, `program.transport.playback.toggle`. @@ -147,3 +163,26 @@ Annotations, argument checking, errors and export progress / cancellation: [agents.md § Conventions](agents.md#conventions). Time is in ticks (254 016 000 000 per second); commands also accept `seconds`, `frame` or `timecode`. + +## Panel docking commands + +`ui.menu.invoke` and `engine.execute` route these frontend commands: + +| Command | Parameters | Result | +| --- | --- | --- | +| `window.floatPanel` | `{panel?, rect?: [x,y,width,height]}` | Move an existing panel into a floating group. Omitted panel uses the focused panel; omitted rect uses a centered 420×320 group. | +| `window.dockPanel` | `{panel?, anchor, zone?: "center"\|"left"\|"right"\|"top"\|"bottom", before?}` | Move into the anchor's tab group or a side split; `before` names an existing destination tab for precise ordering. Floating destinations accept tabs only. | +| `window.closePanel` | `{panel?}` | Close a docked or floating panel without closing its project documents. | + +Invalid source/target identities and non-finite or out-of-range geometry return an error with +the layout unchanged. `ui.inspect.ui.floating` reports floating groups and their logical-point +rectangles. `panel.drag.` locates each panel's header grip; `panel.float..move` +and `.resize` locate floating geometry handles. Named workspace saves include these groups. + +`window.dockAction` accepts `{action}` using the shared docking action's JSON form. It +covers tab moves and ordering, split and floating geometry, activation, and closing. +For example, `{"action":{"ResizeSplit":{"path":[],"size":{"FixedFirst":360}}}}` +resizes the root split; path entries use `false` for the first child and `true` for +the second. `{"action":{"MoveFloating":{"panel":"Info","rect":[45,65,420,320]}}}` +repositions and resizes the floating group containing Info. Invalid actions leave the +layout unchanged. Pointer gestures dispatch through this same catalog command. diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 631678d84..07623219b 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -99,7 +99,7 @@ const SAME_LAYER: &[(&str, &str)] = &[ ]; /// Crates that must not appear below L5 (UI toolkits, windowing, OS audio/menus). -const UI_ONLY: &[&str] = &["egui", "eframe", "egui-wgpu", "winit", "rfd", "cpal", "muda"]; +const UI_ONLY: &[&str] = &["craft-ui", "egui", "eframe", "egui-wgpu", "winit", "rfd", "cpal", "muda"]; fn short(name: &str) -> &str { name.strip_prefix("filmcraft-").unwrap_or(name) 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 124/260] 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 6cceff10e..a6c3f444f 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 789ae78bd..1ebdf0102 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 db2be1923..1cbf5b054 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 125/260] 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 e3431398a..1d35c21e8 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 dd3db365a..15e6ab6fc 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 3bac089ba..99dcccc79 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 6ddfe6ce7..e75837f73 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 126/260] #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 000000000..62610ad1f --- /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 358a6530e..c0095c6e6 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 69d529441..502e30911 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 3532ae7b5..c57e02b65 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 29b2f7067..2e3f7fb80 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 648f217b0..1ea276afe 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 000000000..e11a570fd --- /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 127/260] 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 e74820ab7..819687549 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 fc220e8b3..b7b0c7301 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 b78c57735..cefdfa0db 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 8744da0eb..e42db7558 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 4344cb52f..a796742f9 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 4fe2357fa..d15928e1f 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 acd2bee9b..d5f1ca9dc 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 95f074bb8..1b4491887 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 8acbc1cad..157e536e6 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 76d0c9ccc..74a8e2805 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 e2a41cd77..28f28f344 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 adc0ebb2b..1a6163281 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 4009f3e68..62b8b9bd3 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 83416bf28..0068c37b6 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 29b2f7067..b774a5386 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 856dad73f..a35f48b41 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 128/260] #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 8533f6555..5b75c02f4 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 a64a43c8c..dfc6b7a48 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 3532ae7b5..dc486373a 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 129/260] 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 358a6530e..7bb32e87b 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 130/260] #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 e45965968..e7e0149bf 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 53d2f3247..2561540b6 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 000000000..075f3dddc --- /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 dfb53c5f7..acc2dbe48 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 d015acbe4..c77d97b7a 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 019d81387..b4d4d0672 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 e364402ac..63351636e 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 97de67431..9e0b6c0ad 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 dffa0dd65..4a3a2c53c 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 ef2a69994..ca72c88b6 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 000000000..ee7f335d3 --- /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 7d5679692..eba1ffa04 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 83416bf28..953cd3786 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 3532ae7b5..4507944ca 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 131/260] #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 11707e251..68eb470f1 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 000000000..1d80ba13c --- /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 1d6ff3c4e..22d6629f0 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 06ecfa767..aa07c45ca 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 3532ae7b5..883ae8eb1 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 29b2f7067..335bef34e 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 132/260] 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 38ee823ca..5b6208ed6 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 133/260] #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 8d4035ab3..62549b903 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 d272c41f7..c0494d1d8 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 e0d58794c..6a8c5d711 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 5c4d875b7..9e37a7809 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 57f8850b3..279384f2d 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 4323de772..2a021d499 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 000000000..596413196 --- /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 90dd595f8..4e062d2ab 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 d507639df..8cfb31702 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 f55b52269..51d1cd030 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 65419ebc2..79c7d025d 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 fc8054350..16472ef07 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 426da87cd..30d21268e 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 24a62065a..ed1486af9 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 b19262c84..58c45cb6b 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 3532ae7b5..7ab42d059 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 134/260] 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 7bb32e87b..9a9c40119 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 3b1fa296633d2eec2d9fd01a32fb3f9c7eb39251 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 23:59:15 +0200 Subject: [PATCH 135/260] #790: Matroska AAC / MP3 / AC-3 trims the exact CodecDelay Problem: ffmpeg stores the encoder priming as CodecDelay in ns (1024 AAC samples at 48 kHz = 21.333 ms, 256 AC-3 samples at 44.1 kHz = 5.805 ms) and block timestamps in ms. The demuxer subtracts the delay rounded to 1 ms, and fixed-length packets were anchored on that rounded stamp, so the whole track played up to 16 samples late against ffmpeg's decode. Only Opus undid the rounding. Fix: one stamp-to-samples conversion for every Matroska audio codec that undoes the demuxer's rounding and removes the exact CodecDelay (rounded to the nearest sample; the element holds whole ns), with checked math against hostile timebases. Co-Authored-By: Claude Opus 5.5 --- crates/codecs/src/audio_timing_tests.rs | 29 ++++++++++++ crates/codecs/src/mkv.rs | 42 +++++++++++------ crates/codecs/tests/matroska_audio_oracle.rs | 47 ++++++++++++++++++++ docs/file-format-parity.md | 2 +- docs/gaps.md | 2 + 5 files changed, 108 insertions(+), 14 deletions(-) diff --git a/crates/codecs/src/audio_timing_tests.rs b/crates/codecs/src/audio_timing_tests.rs index 40e43a2d8..62f25cb10 100644 --- a/crates/codecs/src/audio_timing_tests.rs +++ b/crates/codecs/src/audio_timing_tests.rs @@ -176,3 +176,32 @@ fn playing_at_another_rate_leaves_no_holes() { let tail: Vec> = got.iter().map(|c| c[2048..].to_vec()).collect(); assert_eq!(holes(&tail), 0); } + +/// #790: the encoder priming stored as `CodecDelay` (1024 samples: 21.333 ms at 48 kHz, 23.220 ms +/// at 44.1 kHz) is trimmed exactly. The demuxer takes it off the millisecond timestamps rounded to +/// whole milliseconds; starting the first packet there played the track 16 (48 kHz) or 10 (44.1 kHz) +/// samples late. +#[test] +fn matroska_codec_delay_is_trimmed_to_the_sample() { + for rate in [48_000u32, 44_100] { + let (asc, units) = aac(rate); + let want = continuous(&asc, rate, &units); + let delay_ns = (1024 * 1_000_000_000 + rate as i64 / 2) / rate as i64; + let mut spec = TrackSpec::new(TrackKind::Audio, "A_AAC"); + spec.codec_private = asc.clone(); + spec.audio = Some((rate as f64, 2, None)); + spec.codec_delay_ns = delay_ns as u64; + let mut w = MkvWriter::new(Cursor::new(Vec::new()), vec![spec], MuxOptions::default()).unwrap(); + for (k, u) in units.iter().enumerate() { + let start_ns = (k as i64 * 1024 * 1_000_000_000 + rate as i64 / 2) / rate as i64; + w.write_frame(0, start_ns - delay_ns, true, u, None).unwrap(); + } + let bytes: Arc<[u8]> = w.finish().unwrap().into_inner().into(); + let src = MkvSource::open("primed.mkv", bytes).unwrap(); + let frames = units.len().saturating_sub(2) * 1024; + let got = played(&src, rate, frames); + let trimmed: Vec> = want.iter().map(|c| c[1024..].to_vec()).collect(); + let (n, first) = mismatches(&got, &trimmed, 0); + assert_eq!(n, 0, "{rate} Hz: {n} samples differ from the decode with the priming trimmed, first (channel, sample) {first:?}"); + } +} diff --git a/crates/codecs/src/mkv.rs b/crates/codecs/src/mkv.rs index 97de67431..c3ca0355d 100644 --- a/crates/codecs/src/mkv.rs +++ b/crates/codecs/src/mkv.rs @@ -71,15 +71,14 @@ pub struct MkvSource { /// length (AAC, MPEG audio, 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. +/// timestamps. Timestamps are taken with the exact `CodecDelay` off (see [`stamp_to_samples`]). fn audio_starts(file: &MkvFile, bytes: &crate::Src, ti: usize, rate: i64) -> Vec { use crate::audio::{FixedFrames, PacketTime, contiguous_starts, fixed_packet_samples}; let Some(t) = file.tracks.get(ti) else { return Vec::new() }; if let Some(h) = opus_head(&t.codec) { return opus_starts(file, bytes, ti, &h, rate); } - let (n, d) = tb(t); - let at = |pts: i64| (pts as i128 * n as i128 * rate as i128 / d as i128) as i64; + let at = stamp_to_samples(t, rate, codec_delay_samples(t, rate)); let first = || file.read_sample(bytes, ti, 0).unwrap_or_default(); let out_rate = u32::try_from(rate).unwrap_or(0); let frame = match &t.codec { @@ -97,18 +96,13 @@ fn audio_starts(file: &MkvFile, bytes: &crate::Src, ti: usize, rate: i64) -> Vec /// Sample-exact Opus packet start positions (48 kHz frames, pre-skip removed). /// -/// The demuxer also subtracts a rounded `CodecDelay` from the timestamps. Starts are accumulated -/// from each packet's TOC duration, resynchronising to the timestamp only across real gaps (more -/// than two ticks off). Pre-skip is the exact `CodecDelay` (or the header's pre-skip when it is -/// absent). +/// Starts are accumulated from each packet's TOC duration, resynchronising to the timestamp only +/// across real gaps (more than two ticks off). Pre-skip is the exact `CodecDelay` (or the header's +/// pre-skip when it is absent). 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 delay_ns = t.codec_delay_ns as i128; - // 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 skip = if t.codec_delay_ns > 0 { codec_delay_samples(t, rate) } else { head.pre_skip as i64 }; + let at = stamp_to_samples(t, rate, skip); let packets = t.samples.iter().enumerate().map(|(i, s)| crate::audio::PacketTime { stamp: at(s.pts), stamped: own_stamp(s), @@ -117,6 +111,28 @@ fn opus_starts(file: &MkvFile, bytes: &crate::Src, ti: usize, head: &filmcraft_o crate::audio::contiguous_starts(packets, stamp_tolerance(t, rate)) } +/// A block timestamp (with the demuxer's rounded `CodecDelay` taken off) as a position in sample +/// frames at `rate`, `skip` frames of priming removed (#790). The demuxer's rounding is undone first: +/// `CodecDelay` is rarely a whole number of ticks (1024 AAC samples at 48 kHz are 21.333 ms, 256 +/// AC-3 samples at 44.1 kHz 5.805 ms), so starting the first packet at the rounded delay played the +/// whole track up to 16 samples late. +fn stamp_to_samples(t: &filmcraft_matroska::Track, rate: i64, skip: i64) -> impl Fn(i64) -> i64 { + let (n, d) = tb(t); + let delay_ticks = codec_delay_ticks(t); + move |pts: i64| { + let ticks = pts.saturating_add(delay_ticks) as i128; + let at = ticks.checked_mul(n as i128).and_then(|x| x.checked_mul(rate as i128)).map_or(ticks.signum() * i128::MAX, |x| x / d as i128); + i64::try_from(at).unwrap_or(if at < 0 { i64::MIN } else { i64::MAX }).saturating_sub(skip) + } +} + +/// `CodecDelay` in sample frames at `rate`, rounded to the nearest (the element holds whole +/// nanoseconds: 1024 samples at 48 kHz are stored as 21 333 333 ns). +fn codec_delay_samples(t: &filmcraft_matroska::Track, rate: i64) -> i64 { + let ns = (t.codec_delay_ns as i128).saturating_mul(rate.max(0) as i128); + i64::try_from(ns.saturating_add(500_000_000) / 1_000_000_000).unwrap_or(i64::MAX) +} + /// `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 { diff --git a/crates/codecs/tests/matroska_audio_oracle.rs b/crates/codecs/tests/matroska_audio_oracle.rs index 1298ccd48..ff7822740 100644 --- a/crates/codecs/tests/matroska_audio_oracle.rs +++ b/crates/codecs/tests/matroska_audio_oracle.rs @@ -48,3 +48,50 @@ fn matroska_audio_plays_without_holes() { assert!(r_ours < 2.0 * r_theirs + 0.005, "{name}: clicks between packets (|Δ²| {r_ours} vs ffmpeg's {r_theirs})"); } } + +/// The lag in `-max..=max` at which `ours` best matches `theirs` (least mean |difference| over +/// `from..from + len`). +fn best_lag(ours: &[f32], theirs: &[f32], from: usize, len: usize, max: i64) -> i64 { + let err = |lag: i64| -> f64 { (from..from + len).map(|i| (ours[i] as f64 - theirs[(i as i64 + lag) as usize] as f64).abs()).sum::() / len as f64 }; + (-max..=max).min_by(|&a, &b| err(a).total_cmp(&err(b))).unwrap() +} + +/// #790: ffmpeg stores the encoder priming as `CodecDelay` (1024 samples = 21.333 ms for AAC at +/// 48 kHz, 256 = 5.805 ms for AC-3 at 44.1 kHz, 1105 = 25.057 ms for MP3), and block timestamps to +/// the millisecond. Taking the rounded delay as the start of the first packet played the whole +/// track up to 16 samples late; the decode must line up with ffmpeg's to the sample. +#[test] +fn matroska_audio_is_sample_aligned_with_ffmpeg() { + let ff = filmcraft_testkit::require_ffmpeg!(); + let cases: [(&str, u32, &[&str]); 5] = [ + ("noise_aac_48k.mkv", 48_000, &["-c:a", "aac", "-b:a", "256k"]), + ("noise_aac_44k.mkv", 44_100, &["-c:a", "aac", "-b:a", "256k"]), + ("noise_ac3_44k.mkv", 44_100, &["-c:a", "ac3", "-b:a", "448k"]), + ("noise_ac3_48k.mkv", 48_000, &["-c:a", "ac3", "-b:a", "448k"]), + ("noise_mp3_44k.mkv", 44_100, &["-c:a", "libmp3lame", "-b:a", "320k"]), + ]; + for (name, rate, codec) in cases { + let input = format!("anoisesrc=color=pink:seed=7:amplitude=0.5:sample_rate={rate}:duration=2"); + let args: Vec<&str> = ["-f", "lavfi", "-i", input.as_str(), "-ac", "1"].iter().copied().chain(codec.iter().copied()).collect(); + let Some(path) = fixture(&ff, name, &args) else { + eprintln!("SKIPPED ({name}): this ffmpeg can't encode it"); + continue; + }; + let src = filmcraft_codecs::open_bytes(name, bytes(&path)).unwrap(); + let frames = rate as usize * 3 / 2; + let mut ours = Vec::with_capacity(frames); + while ours.len() < frames { + let n = 1000.min(frames - ours.len()); + ours.extend_from_slice(&src.audio(ours.len() as i64, n, rate).unwrap().channels[0]); + } + let theirs = ffmpeg_audio_f32(&ff, &path, &[]); + assert!(theirs.len() >= frames, "{name}: ffmpeg decoded {} samples", theirs.len()); + let lag = best_lag(&ours, &theirs, rate as usize / 4, rate as usize, 40); + assert_eq!(lag, 0, "{name}: our decode is {lag} samples off ffmpeg's"); + // read from the middle, as a seek does + let at = rate as i64 / 2; + let mid = src.audio(at, 2000, rate).unwrap().channels[0].clone(); + let diff = mid.iter().zip(&ours[at as usize..]).map(|(a, b)| (a - b).abs()).fold(0f32, f32::max); + assert!(diff < 1e-3, "{name}: a read from {at} differs from continuous decoding by {diff}"); + } +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b19262c84..125eec7dc 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -39,7 +39,7 @@ only). "Tested" names the evidence. | 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% | +| 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; audio sample-aligned with ffmpeg (#790) | 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% | diff --git a/docs/gaps.md b/docs/gaps.md index b479a6d1b..ba593bcbb 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -163,6 +163,8 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** - **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). +- **Have:** Matroska AAC / MP3 / AC-3 sample-aligned with ffmpeg's decode (the exact + `CodecDelay` is trimmed, not the 1 ms-rounded one; #790). ## G11. No plugin hosting From 0b62ebb9ad7bcfdf200adb4210c88247033e756d Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 00:08:40 +0200 Subject: [PATCH 136/260] #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 47decaf28..0f5c47600 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 fbcbabb57..bae155c95 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 b479a6d1b..094614106 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 611bb8278..758449193 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 137/260] #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 ce7bd609e..0c1793ed1 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 4c5c2be9a..9566bc599 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 562575f2d..61241a8a2 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 5a14d32cc..e6f91d785 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 fd442cef6..6df9e2462 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 1c723fea4..69ccdc1e3 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 000000000..f9f8c6601 --- /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 000000000..30609ac7f --- /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 1cbf5b054..7225b5c98 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 c0095c6e6..97ad0f1e3 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 b479a6d1b..b903c559d 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 a4a18386f..6124d1c7a 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 138/260] #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 63351636e..daba8f4cd 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 4a3a2c53c..c7ea9d069 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 012424d77..cbb71ba0e 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 d74ae54a3..47c31bb43 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 b9afd53ea..6d2ff79b2 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 ba0da95b9..8fe1ff6bc 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 56eddd327..4e14ba75a 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 139/260] #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 92b333cde..4dd0d4d0e 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 f113e9049..8079502b7 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 31f5edfc0..31eed64ca 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 208a74ee3..14756eef9 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 91f56bba1..82692a175 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 7af9bb249..1cf8ddfd5 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 ceb515f2c..27c26922e 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 b209a7825..a096c55fe 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 ab25b8af5..628bd5a2e 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 7703c9a71..e74879f6d 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 140/260] #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 e3b162fdc..72a32cb8d 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 6a8c5d711..b6d65d101 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 279384f2d..c868e91c4 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 2a021d499..3846ce9a2 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 596413196..74ea4081e 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 61ee9dbd0..162c958ab 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 8cfb31702..5381a1943 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 51d1cd030..e8fd5a329 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 79c7d025d..defaea9ab 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 16472ef07..21b843066 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 ed1486af9..1f83f3534 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 58c45cb6b..b62d78530 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 ab25b8af5..164ede9b2 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 141/260] 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 38ee823ca..5f8981665 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 9aef769ed..95932cb16 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 d4ee713e6..25b9e294e 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 142/260] #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 2a53cfdf4..ffa5a5a16 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 549a64fc6..1fc069a84 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 d2266b4d7..764a27a5c 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 ed1486af9..8b09b6788 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 aec13ce2c..c5c87ce50 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 143/260] 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 a57d0483a..369305352 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 15e6ab6fc..501ead7a5 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 ca4fab0dc..70cde2b69 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 1339ad6c2..92b34a1fc 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 3610c9ea3..f8cdd36d9 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 144/260] #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 9ead306d5..10592444f 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 e3b162fdc..c31894f38 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 6a8c5d711..82245e97a 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 9e37a7809..b714e284f 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 000000000..0e18b1fdf --- /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 279384f2d..9a7d15dcf 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 2a021d499..c6f049799 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 61ee9dbd0..4bfa28ecb 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 8cfb31702..1b72f10ba 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 51d1cd030..4f76c350c 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 f9f8c6601..5f17a4f44 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 79c7d025d..630cbd4d2 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 16472ef07..2d5188664 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 ed1486af9..cbd1e6ed7 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 58c45cb6b..ee18a834e 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 aec13ce2c..c5baf693b 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 145/260] #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 db7745dd0..4fc6638e7 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 000000000..2d5832faf --- /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 bfc94de79..a73451e7d 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 000000000..06e9295db --- /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 b62d78530..2ac9b655c 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 83fb12b36..8b4b1bb52 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 116364cf083bf3e6c7aa95f5d71318ae366ee802 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 00:58:45 +0200 Subject: [PATCH 146/260] #820: fall back to DX12, then a software adapter, when the graphics start fails Problem: a graphics driver that crashes inside wgpu instance or device creation (Intel Vulkan igvk64.dll #512 #413 #793, AMD atio6axx.dll #477) killed every launch the same way until the user set WGPU_BACKEND by hand; a start-up error wgpu did report ended in an error dialog. Fix: each start leaves a marker in the data folder (graphics-start.json), cleared once the window has painted two frames. A marker still pending at the next launch moves on along the platform's ladder (Windows: Vulkan+DX12, DX12 only, software/WARP; Linux: default, software/lavapipe or llvmpipe) and keeps the mode that works. A wgpu start-up error moves on within the same process (eframe reuses its event loop). Fallback modes don't register the GPU export renderer, whose own instance would load the failed driver. An explicit WGPU_BACKEND leaves the marker alone. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + apps/filmcraft/Cargo.toml | 2 + apps/filmcraft/src/args.rs | 2 +- apps/filmcraft/src/graphics.rs | 391 +++++++++++++++++++++++++++++++-- apps/filmcraft/src/main.rs | 361 ++++++++++++++++-------------- docs/gaps.md | 12 +- 6 files changed, 582 insertions(+), 187 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5529201a2..77d378546 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1687,6 +1687,7 @@ dependencies = [ "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "open", + "pollster 0.4.0", "rfd", "serde_json", "sys-locale", diff --git a/apps/filmcraft/Cargo.toml b/apps/filmcraft/Cargo.toml index 85175489d..3ae35e6c6 100644 --- a/apps/filmcraft/Cargo.toml +++ b/apps/filmcraft/Cargo.toml @@ -41,6 +41,8 @@ open = "5" [dev-dependencies] glob = "0.3" +# graphics.rs: enumerate the real adapters in a test. +pollster = "0.4" [target.'cfg(target_os = "macos")'.dependencies] muda = "0.21" diff --git a/apps/filmcraft/src/args.rs b/apps/filmcraft/src/args.rs index 6460a9684..c923112c6 100644 --- a/apps/filmcraft/src/args.rs +++ b/apps/filmcraft/src/args.rs @@ -26,7 +26,7 @@ pub enum Cli { } /// Options of a normal start. -#[derive(Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq)] pub struct Launch { pub control_port: Option, /// A project and / or media files to open. diff --git a/apps/filmcraft/src/graphics.rs b/apps/filmcraft/src/graphics.rs index b2be99cbf..511ec982c 100644 --- a/apps/filmcraft/src/graphics.rs +++ b/apps/filmcraft/src/graphics.rs @@ -1,33 +1,237 @@ -//! Windows graphics instance configuration, applied before wgpu can initialize any driver. +//! Graphics start-up: the wgpu backends and adapter the window is created with, and the fallback +//! when a start fails (docs/gaps.md G4, #820). +//! +//! A graphics driver can crash inside wgpu instance or device creation (Intel's Vulkan driver +//! `igvk64.dll`, #512 #413; AMD's OpenGL driver `atio6axx.dll`, #477), where no Rust error +//! handling runs, so every launch died the same way until the user set `WGPU_BACKEND` by hand. +//! Each start now leaves a marker in the data folder ([`STATE_FILE`]) that is cleared once the +//! window has painted. A marker still pending at the next launch means that start died: the launch +//! moves on to the next [`Mode`] of the platform's [`LADDER`] and keeps it. A start-up error that +//! wgpu does report (no adapter, device creation failed) moves on within the same process. An +//! explicit `WGPU_BACKEND` leaves the marker alone: the user is in charge. -use eframe::{NativeOptions, egui_wgpu::WgpuSetup, wgpu::Backends}; +use std::io::Read as _; +use std::path::{Path, PathBuf}; +use std::sync::Arc; -/// Restrict the wgpu instance to the primary backends (Vulkan, DirectX 12, Metal), i.e. leave -/// OpenGL out, unless `backend_override` (normally `Backends::from_env()`, i.e. `WGPU_BACKEND`) -/// names other backends. -pub fn configure(options: &mut NativeOptions, backend_override: Option) { - if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup { - // eframe's default includes GL. Creating that backend can crash inside AMD's - // atio6axx.dll before adapter selection or any Rust error handling runs, so the - // window never appears. Choosing an adapter afterwards is too late: only leaving GL - // out of the instance avoids it. Keep Vulkan and DX12 both: forcing DX12 alone sends - // shaders through FXC (which rejects fx.wgsl, silently falling back to the CPU) and - // leaves machines without DX12 with no backend at all. - create.instance_descriptor.backends = backend_override.unwrap_or(Backends::PRIMARY); +use eframe::{ + NativeOptions, + egui_wgpu::WgpuSetup, + wgpu::{self, Backends}, +}; + +/// The start marker in the data folder: `{"mode": "", "pending": }`. Delete it to +/// start with the default graphics mode again. +pub const STATE_FILE: &str = "graphics-start.json"; + +/// How the window's graphics device is created. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Mode { + /// The platform default: Vulkan or DirectX 12 on Windows (no OpenGL, see [`apply`]), Metal on + /// macOS, Vulkan or OpenGL elsewhere. + Default, + /// DirectX 12 only (Windows), for Vulkan drivers that crash at start-up. + Dx12, + /// A software adapter (WARP on Windows, lavapipe / llvmpipe elsewhere); the preview then + /// composites on the CPU (`FilmcraftApp::set_wgpu`). + Software, +} + +impl Mode { + pub fn name(self) -> &'static str { + match self { + Mode::Default => "default", + Mode::Dx12 => "dx12", + Mode::Software => "software", + } } + + fn parse(name: &str) -> Option { + [Mode::Default, Mode::Dx12, Mode::Software].into_iter().find(|m| m.name() == name) + } +} + +/// The modes tried in order on this platform. macOS has only Metal, and no software adapter. +pub const LADDER: &[Mode] = if cfg!(target_os = "windows") { + &[Mode::Default, Mode::Dx12, Mode::Software] +} else if cfg!(target_os = "macos") { + &[Mode::Default] +} else { + &[Mode::Default, Mode::Software] +}; + +/// The mode after `mode` in `ladder`, if any. +fn next_in(ladder: &[Mode], mode: Mode) -> Option { + let i = ladder.iter().position(|m| *m == mode)?; + ladder.get(i.checked_add(1)?).copied() +} + +/// This launch's mode from the saved marker (`(mode, pending)`; `None` when there is none or it +/// is unreadable), and whether it moved on because the last start died. +fn launch_mode(saved: Option<(Mode, bool)>, ladder: &[Mode]) -> (Mode, bool) { + let first = ladder.first().copied().unwrap_or(Mode::Default); + match saved { + // A mode this platform does not use (a data folder copied from another OS): start over. + Some((mode, _)) if !ladder.contains(&mode) => (first, false), + Some((mode, true)) => match next_in(ladder, mode) { + Some(next) => (next, true), + // Nothing left to try: the last mode is still the best chance. + None => (mode, false), + }, + Some((mode, false)) => (mode, false), + None => (first, false), + } +} + +/// `(mode, pending)` from the marker's text; `None` for anything else. +fn parse_state(text: &str) -> Option<(Mode, bool)> { + let v: serde_json::Value = serde_json::from_str(text).ok()?; + Some((Mode::parse(v.get("mode")?.as_str()?)?, v.get("pending")?.as_bool()?)) +} + +fn read_state(path: &Path) -> Option<(Mode, bool)> { + // The marker is a few bytes; anything longer is not ours. + let mut text = String::new(); + std::fs::File::open(path).ok()?.take(4096).read_to_string(&mut text).ok()?; + parse_state(&text) +} + +fn write_state(path: &Path, mode: Mode, pending: bool) -> std::io::Result<()> { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + let text = serde_json::json!({ "mode": mode.name(), "pending": pending }).to_string(); + // Written beside and renamed, so a start that dies mid-write leaves the old marker whole. + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, text)?; + std::fs::rename(&tmp, path) +} + +/// This launch's graphics mode and its start marker. +#[derive(Clone, Debug)] +pub struct GraphicsStart { + /// The marker; `None` without a data folder or with `WGPU_BACKEND` set. + path: Option, + mode: Mode, +} + +impl GraphicsStart { + /// Pick this launch's mode from the marker in `data_dir` and mark the start pending. + /// `explicit_backend` (`WGPU_BACKEND` set) starts in [`Mode::Default`] and leaves the marker + /// alone. + pub fn begin(data_dir: Option<&Path>, explicit_backend: bool) -> Self { + let path = if explicit_backend { None } else { data_dir.map(|d| d.join(STATE_FILE)) }; + let saved = path.as_deref().and_then(read_state); + let (mode, moved_on) = launch_mode(saved, LADDER); + let file = path.as_deref().map(|p| p.display().to_string()).unwrap_or_default(); + if moved_on { + let failed = saved.map_or("default", |(m, _)| m.name()); + log::warn!( + "the last start did not get its window up with graphics mode {failed}; starting with {} (delete {file} to try the default again)", + mode.name() + ); + } else if mode != Mode::Default { + log::info!("graphics mode {} after an earlier failed start (delete {file} to try the default again)", mode.name()); + } + let start = Self { path, mode }; + start.save(true); + start + } + + pub fn mode(&self) -> Mode { + self.mode + } + + /// Move on to the next mode after a start-up error wgpu reported; `false` when none is left. + pub fn step_down(&mut self) -> bool { + let Some(next) = next_in(LADDER, self.mode) else { + return false; + }; + log::warn!("graphics mode {} failed to start; trying {}", self.mode.name(), next.name()); + self.mode = next; + self.save(true); + true + } + + /// The window is up: keep this mode for the next launches. + pub fn confirm(&self) { + self.save(false); + } + + fn save(&self, pending: bool) { + if let Some(path) = &self.path + && let Err(e) = write_state(path, self.mode, pending) + { + log::warn!("can't write {}: {e}", path.display()); + } + } +} + +/// Configure `options` for `mode`, before eframe creates the wgpu instance. `backend_override` +/// is normally `Backends::from_env()`, i.e. `WGPU_BACKEND`. +pub fn apply(options: &mut NativeOptions, mode: Mode, backend_override: Option) { + apply_for(options, mode, backend_override, cfg!(target_os = "windows")); +} + +fn apply_for(options: &mut NativeOptions, mode: Mode, backend_override: Option, windows: bool) { + let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup else { + return; + }; + match mode { + // eframe's default (`WGPU_BACKEND`, else PRIMARY | GL), except on Windows: there it + // includes GL. Creating that backend can crash inside AMD's atio6axx.dll before adapter + // selection or any Rust error handling runs, so the window never appears. Choosing an + // adapter afterwards is too late: only leaving GL out of the instance avoids it. Keep + // Vulkan and DX12 both: forcing DX12 alone sends shaders through FXC (which rejects + // fx.wgsl, silently falling back to the CPU) and leaves machines without DX12 with no + // backend at all. + Mode::Default if windows => create.instance_descriptor.backends = backend_override.unwrap_or(Backends::PRIMARY), + Mode::Default => {} + // Without Vulkan, whose driver is what crashes; GPU effects may fall back to the CPU. + Mode::Dx12 => create.instance_descriptor.backends = Backends::DX12, + Mode::Software => { + create.instance_descriptor.backends = software_backends(windows); + create.native_adapter_selector = Some(Arc::new(pick_software)); + } + } +} + +/// The backends a software adapter is looked for in: WARP is a DX12 adapter, and GL stays out on +/// Windows for the reason in [`apply_for`]. +fn software_backends(windows: bool) -> Backends { + if windows { Backends::DX12 } else { Backends::VULKAN | Backends::GL } +} + +/// The first software adapter that can draw to the window's surface. +fn pick_software(adapters: &[wgpu::Adapter], surface: Option<&wgpu::Surface<'_>>) -> Result { + adapters + .iter() + .find(|a| filmcraft_ui_egui::software_rasterizer(&a.get_info()).is_some() && surface.is_none_or(|s| a.is_surface_supported(s))) + .cloned() + .ok_or_else(|| format!("no software graphics adapter (WARP, lavapipe, llvmpipe) among {} adapters", adapters.len())) +} + +/// Whether `err` from `eframe::run_native` is a graphics start-up failure that another mode may +/// avoid (as opposed to a display, event-loop or app error). +pub fn is_graphics_failure(err: &eframe::Error) -> bool { + matches!(err, eframe::Error::Wgpu(_)) } #[cfg(test)] mod tests { use super::*; - fn configured_backends(backend_override: Option) -> Backends { + const WINDOWS: &[Mode] = &[Mode::Default, Mode::Dx12, Mode::Software]; + const LINUX: &[Mode] = &[Mode::Default, Mode::Software]; + const MACOS: &[Mode] = &[Mode::Default]; + + fn configured(mode: Mode, backend_override: Option, windows: bool) -> (Backends, bool) { let mut options = NativeOptions::default(); - configure(&mut options, backend_override); + apply_for(&mut options, mode, backend_override, windows); let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else { panic!("default native options must create a wgpu instance"); }; - create.instance_descriptor.backends + (create.instance_descriptor.backends, create.native_adapter_selector.is_some()) } #[test] @@ -37,16 +241,159 @@ mod tests { panic!("default native options must create a wgpu instance"); }; assert!(eframe_default.instance_descriptor.backends.contains(Backends::GL)); - let configured = configured_backends(None); - assert_eq!(configured, Backends::PRIMARY); - assert!(!configured.contains(Backends::GL)); - assert!(configured.contains(Backends::DX12) && configured.contains(Backends::VULKAN)); + let (backends, selector) = configured(Mode::Default, None, true); + assert_eq!(backends, Backends::PRIMARY); + assert!(!backends.contains(Backends::GL)); + assert!(backends.contains(Backends::DX12) && backends.contains(Backends::VULKAN)); + assert!(!selector); } #[test] fn explicit_backend_override_is_preserved() { for backends in [Backends::VULKAN, Backends::GL, Backends::DX12, Backends::VULKAN | Backends::DX12, Backends::empty()] { - assert_eq!(configured_backends(Some(backends)), backends); + assert_eq!(configured(Mode::Default, Some(backends), true).0, backends); } } + + #[test] + fn fallback_modes_leave_out_the_crashing_backends() { + // DX12 only: Intel's and AMD's Vulkan drivers are what crashed (#512, #413). + assert_eq!(configured(Mode::Dx12, None, true), (Backends::DX12, false)); + // Software on Windows is WARP (DX12), never GL (atio6axx.dll, #477). + assert_eq!(configured(Mode::Software, None, true), (Backends::DX12, true)); + assert_eq!(configured(Mode::Software, None, false), (Backends::VULKAN | Backends::GL, true)); + // Off Windows the default is eframe's own. + let WgpuSetup::CreateNew(eframe_default) = NativeOptions::default().wgpu_options.wgpu_setup else { + panic!("default native options must create a wgpu instance"); + }; + assert_eq!(configured(Mode::Default, None, false), (eframe_default.instance_descriptor.backends, false)); + } + + #[test] + fn a_start_that_died_moves_on_to_the_next_mode_and_keeps_it() { + // first launch + assert_eq!(launch_mode(None, WINDOWS), (Mode::Default, false)); + // the default start died in the driver: DX12 next, then software + assert_eq!(launch_mode(Some((Mode::Default, true)), WINDOWS), (Mode::Dx12, true)); + assert_eq!(launch_mode(Some((Mode::Dx12, true)), WINDOWS), (Mode::Software, true)); + assert_eq!(launch_mode(Some((Mode::Default, true)), LINUX), (Mode::Software, true)); + // a confirmed mode is kept + assert_eq!(launch_mode(Some((Mode::Dx12, false)), WINDOWS), (Mode::Dx12, false)); + assert_eq!(launch_mode(Some((Mode::Default, false)), WINDOWS), (Mode::Default, false)); + // nothing left to try: stay + assert_eq!(launch_mode(Some((Mode::Software, true)), WINDOWS), (Mode::Software, false)); + assert_eq!(launch_mode(Some((Mode::Default, true)), MACOS), (Mode::Default, false)); + // a mode this platform has not: start over + assert_eq!(launch_mode(Some((Mode::Dx12, false)), LINUX), (Mode::Default, false)); + assert_eq!(launch_mode(Some((Mode::Software, true)), MACOS), (Mode::Default, false)); + assert_eq!(launch_mode(None, &[]), (Mode::Default, false)); + } + + #[test] + fn the_marker_round_trips_and_rejects_anything_else() { + for mode in WINDOWS { + for pending in [false, true] { + let text = serde_json::json!({ "mode": mode.name(), "pending": pending }).to_string(); + assert_eq!(parse_state(&text), Some((*mode, pending))); + } + } + for bad in [ + "", + "{", + "null", + "[]", + r#"{"mode":"vulkan","pending":true}"#, + r#"{"mode":"dx12"}"#, + r#"{"mode":1,"pending":true}"#, + r#"{"mode":"dx12","pending":"yes"}"#, + ] { + assert_eq!(parse_state(bad), None, "{bad}"); + } + } + + fn temp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("filmcraft-graphics-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + dir + } + + #[test] + fn begin_marks_the_start_pending_and_confirm_clears_it() { + let dir = temp_dir("marker"); + let file = dir.join(STATE_FILE); + let start = GraphicsStart::begin(Some(&dir), false); + assert_eq!(start.mode(), Mode::Default); + assert_eq!(read_state(&file), Some((Mode::Default, true))); + start.confirm(); + assert_eq!(read_state(&file), Some((Mode::Default, false))); + // a confirmed start is kept on the next launch + assert_eq!(GraphicsStart::begin(Some(&dir), false).mode(), Mode::Default); + // ... and one that never confirmed moves on (Windows and Linux; macOS has nowhere to go) + let next = GraphicsStart::begin(Some(&dir), false); + assert_eq!(next.mode(), LADDER.get(1).copied().unwrap_or(Mode::Default)); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_corrupt_or_huge_marker_starts_with_the_default() { + let dir = temp_dir("corrupt"); + let file = dir.join(STATE_FILE); + let _ = std::fs::create_dir_all(&dir); + for text in ["garbage".to_string(), format!(r#"{{"mode":"dx12","pending":true,"pad":"{}"}}"#, "x".repeat(10_000))] { + let _ = std::fs::write(&file, text); + let start = GraphicsStart::begin(Some(&dir), false); + assert_eq!(start.mode(), Mode::Default); + // and it is replaced with a well-formed pending marker + assert_eq!(read_state(&file), Some((Mode::Default, true))); + } + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn an_explicit_backend_leaves_the_marker_alone() { + let dir = temp_dir("explicit"); + let file = dir.join(STATE_FILE); + let _ = std::fs::create_dir_all(&dir); + let _ = write_state(&file, Mode::Software, true); + let mut start = GraphicsStart::begin(Some(&dir), true); + assert_eq!(start.mode(), Mode::Default); + start.confirm(); + let _ = start.step_down(); + assert_eq!(read_state(&file), Some((Mode::Software, true))); + // no data folder: nothing is written, the default still starts + assert_eq!(GraphicsStart::begin(None, false).mode(), Mode::Default); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn step_down_walks_the_ladder_and_records_each_attempt() { + let dir = temp_dir("step"); + let file = dir.join(STATE_FILE); + let mut start = GraphicsStart::begin(Some(&dir), false); + for expected in LADDER.iter().skip(1) { + assert!(start.step_down()); + assert_eq!(start.mode(), *expected); + assert_eq!(read_state(&file), Some((*expected, true))); + } + assert!(!start.step_down(), "nothing after the last mode"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_software_selector_only_picks_a_software_adapter() { + if wgpu::Instance::enabled_backend_features().is_empty() { + return; + } + // only the backends software mode uses: no driver this test loads is one the app avoids + let backends = software_backends(cfg!(target_os = "windows")); + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { backends, ..wgpu::InstanceDescriptor::new_without_display_handle() }); + let adapters = pollster::block_on(instance.enumerate_adapters(backends)); + match pick_software(&adapters, None) { + Ok(adapter) => assert!(filmcraft_ui_egui::software_rasterizer(&adapter.get_info()).is_some()), + Err(why) => { + assert!(adapters.iter().all(|a| filmcraft_ui_egui::software_rasterizer(&a.get_info()).is_none()), "{why}"); + } + } + assert!(pick_software(&[], None).is_err()); + } } diff --git a/apps/filmcraft/src/main.rs b/apps/filmcraft/src/main.rs index 591bc00fb..86bac7825 100644 --- a/apps/filmcraft/src/main.rs +++ b/apps/filmcraft/src/main.rs @@ -31,7 +31,6 @@ mod control_server; #[cfg(target_os = "linux")] mod dev_icon; mod file_filters; -#[cfg(any(target_os = "windows", test))] mod graphics; mod logging; #[cfg(target_os = "macos")] @@ -44,6 +43,8 @@ use filmcraft_engine::Session; use filmcraft_engine::autosave::{AutosaveConfig, default_data_dir}; use filmcraft_ui_egui::FilmcraftApp; use serde_json::json; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; /// Reverse-DNS app id: Linux `.desktop` file, hicolor icon name and Wayland app id. const APP_ID: &str = "ai.storyteller.filmcraft"; @@ -80,7 +81,7 @@ fn main() -> eframe::Result { let logger = logging::install(); // lossy, so a value that is not Unicode is reported like any other bad port instead of ignored let env_port = std::env::var_os("FILMCRAFT_CONTROL_PORT").map(|p| p.to_string_lossy().into_owned()); - let Launch { control_port, files, demo, startup_flag, recover, data_dir } = match args::parse(env_port, std::env::args().skip(1)) { + let launch = match args::parse(env_port, std::env::args().skip(1)) { Ok(Cli::Run(l)) => l, Ok(Cli::Help) => { tell(args::USAGE, false); @@ -95,7 +96,8 @@ fn main() -> eframe::Result { std::process::exit(2); } }; - let log_dir = data_dir.clone().or_else(default_data_dir).map(|d| d.join(logging::LOG_DIR)); + let data_root = launch.data_dir.clone().or_else(default_data_dir); + let log_dir = data_root.as_ref().map(|d| d.join(logging::LOG_DIR)); log::info!("FilmCraft {} ({} {})", env!("CARGO_PKG_VERSION"), std::env::consts::OS, std::env::consts::ARCH); // The log file lives next to the crash logs; opened after the arguments, so `--help`, // `--version` and usage errors leave no file behind. Records logged until now go in first. @@ -118,165 +120,35 @@ fn main() -> eframe::Result { // OS hardware video decoders (VideoToolbox on macOS) in front of our own; Settings ▸ Playback ▸ // Hardware decoding switches them off. Unsupported streams and failures use our decoders. register_hardware_decoders(); - register_gpu_frame_renderer(); - let options = eframe::NativeOptions { - viewport: egui::ViewportBuilder::default() - .with_title("FilmCraft") - .with_inner_size([1600.0, 980.0]) - .with_min_inner_size([900.0, 560.0]) - .with_drag_and_drop(true) - .with_fullsize_content_view(true) - .with_titlebar_shown(false) - .with_title_shown(false) - // Started for an agent (`--control`): open without taking the user's keyboard focus. - .with_active(control_port.is_none()) - .with_icon(app_icon()) - // Wayland matches the window to `packaging/linux/ai.storyteller.filmcraft.desktop`. - .with_app_id(APP_ID), - event_loop_builder: agent_event_loop(control_port.is_some()), - ..Default::default() + // Which graphics backends and adapter the window starts with: the mode after the last one + // if the previous start died before its window painted (a driver crash), see graphics.rs. + let backend_override = eframe::wgpu::Backends::from_env(); + let mut graphics = graphics::GraphicsStart::begin(data_root.as_deref(), backend_override.is_some()); + // The GPU export renderer creates its own wgpu instance with every backend; after a start + // fell back, that would load the driver that failed. Exports then composite on the CPU. + if graphics.mode() == graphics::Mode::Default { + register_gpu_frame_renderer(); + } + let started = loop { + let mut options = native_options(launch.control_port.is_some()); + // Before eframe creates the wgpu instance (e.g. no OpenGL on Windows, see graphics.rs). + graphics::apply(&mut options, graphics.mode(), backend_override); + let app_created = Arc::new(AtomicBool::new(false)); + let result = eframe::run_native("FilmCraft", options, app_creator(launch.clone(), graphics.clone(), Arc::clone(&app_created))); + let app_created = app_created.load(Ordering::Relaxed); + match &result { + // wgpu reported the failure (no adapter, device creation failed) before the app + // existed: try the next mode in this process. + Err(e) if !app_created && graphics::is_graphics_failure(e) && graphics.step_down() => { + log::warn!("graphics start failed: {e}"); + continue; + } + // Closed before the second frame: the window still came up. + Ok(()) if app_created => graphics.confirm(), + _ => {} + } + break result; }; - // Before eframe creates the wgpu instance: leave OpenGL out on Windows (see graphics.rs). - #[cfg(target_os = "windows")] - let options = { - let mut options = options; - graphics::configure(&mut options, eframe::wgpu::Backends::from_env()); - options - }; - let started = eframe::run_native( - "FilmCraft", - options, - Box::new(move |cc| { - let mut session = Session::default(); - if let Some(dir) = data_dir.clone().or_else(default_data_dir) { - let mut cfg = AutosaveConfig::new(dir); - cfg.local_offset = local_offset; - if let Err(e) = session.start_autosave(cfg) { - log::warn!("auto-save and crash recovery unavailable: {e}"); - } - } - // voice-over recording reads the microphone through cpal - session.voiceover.input = Some(Box::new(audio_in::CpalIn::new(&session.prefs.audio_hardware.device_class))); - let project = files.iter().find(|f| f.ends_with(".fcproj")).cloned(); - if let Some(p) = project { - if let Err(e) = session.execute("file.open", json!({"path": p})) { - log::error!("{e}"); - } - } else if !startup_flag && session.prefs.general.at_startup == "openMostRecent" { - // Settings ▸ General ▸ At Startup ▸ Open Most Recent - let recent = session.prefs.general.recent_projects.iter().find(|p| std::path::Path::new(p).exists()).cloned(); - match recent { - Some(p) => { - if let Err(e) = session.execute("file.open", json!({"path": p})) { - log::error!("{e}"); - } - } - None => { - let _ = session.execute("file.openDemoProject", json!({})); - } - } - } else if !startup_flag && session.prefs.general.at_startup == "emptyProject" { - } else if demo { - let _ = session.execute("file.openDemoProject", json!({})); - } - let media: Vec = files.iter().filter(|f| !f.ends_with(".fcproj")).cloned().collect(); - if !media.is_empty() { - let _ = session.execute("file.import", json!({"paths": media})); - } - if recover == Some(true) && !session.recovery_candidates().is_empty() { - let id = session.recovery_candidates()[0].id.clone(); - match session.execute("file.recover", json!({"id": id})) { - Ok(r) => log::info!("recovered {r}"), - Err(e) => log::error!("recovery failed: {e}"), - } - } - let mut app = FilmcraftApp::new(session); - if recover == Some(false) { - app.dialog = None; - } - app.integrated_titlebar = cfg!(target_os = "macos"); - if let Some(rs) = cc.wgpu_render_state.clone() - && std::env::var_os("FILMCRAFT_CPU_COMPOSITE").is_none() - { - app.set_wgpu(rs); - } - // Keep device selection available even when the default device is unavailable. - // Settings ▸ Audio Hardware is applied on the first frame (`apply_prefs`). - app.audio = Some(Box::new(audio::CpalOut::new())); - app.hooks.pick_files = Some(Box::new(|exts: &[&str]| { - rfd::FileDialog::new() - .add_filter(filmcraft_ui_egui::i18n::t("Media"), &file_filters::extensions(exts)) - .pick_files() - .unwrap_or_default() - .into_iter() - .map(|p| p.to_string_lossy().to_string()) - .collect() - })); - // Link Media ▸ Locate…, Attach Proxies, Reconnect Full Resolution: one path, not imported. - app.hooks.pick_file_for_relink = Some(Box::new(|exts: &[&str], _hint| { - rfd::FileDialog::new() - .add_filter(filmcraft_ui_egui::i18n::t("Media"), &file_filters::extensions(exts)) - .pick_file() - .map(|p| p.to_string_lossy().to_string()) - })); - app.hooks.pick_save = Some(Box::new(|name: &str| { - rfd::FileDialog::new() - .add_filter(filmcraft_ui_egui::i18n::t("FilmCraft Project"), &file_filters::extensions(&["fcproj"])) - .set_file_name(name) - .save_file() - .map(|p| p.to_string_lossy().to_string()) - })); - app.hooks.pick_save_as = Some(Box::new(|filter: &str, exts: &[&str], name: &str| { - rfd::FileDialog::new() - .add_filter(filmcraft_ui_egui::i18n::t(filter), &file_filters::extensions(exts)) - .set_file_name(name) - .save_file() - .map(|p| p.to_string_lossy().to_string()) - })); - app.hooks.pick_folder = Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string()))); - 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()))); - 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())); - // Settings ▸ Appearance ▸ Appearance Mode ▸ Sync with system on Linux desktops whose - // compositor reports no theme to winit (no polling: see appearance.rs). - app.hooks.system_theme = appearance::service(); - app.hooks.raise_without_focus = Some(Box::new(|| { - window_raise::raise_without_focus(); - })); - app.hooks.pick_open_file = Some(Box::new(|filter: &str, exts: &[&str]| { - rfd::FileDialog::new() - .add_filter(filmcraft_ui_egui::i18n::t(filter), &file_filters::extensions(exts)) - .pick_file() - .map(|p| p.to_string_lossy().to_string()) - })); - app.hooks.pick_open_project = Some(Box::new(|| { - rfd::FileDialog::new() - .add_filter(filmcraft_ui_egui::i18n::t("FilmCraft Project"), &file_filters::extensions(&["fcproj"])) - .pick_file() - .map(|p| p.to_string_lossy().to_string()) - })); - #[cfg(target_os = "macos")] - { - let (rx, update) = native_menu::install(&app, cc.egui_ctx.clone()); - app.command_inbox = Some(rx); - app.hooks.shortcuts_changed = Some(update); - app.ui.show_menu_bar = false; - } - if let Some(port) = control_port { - let rx = control_server::start(port, cc.egui_ctx.clone()); - app = app.with_control(rx); - } - Ok(Box::new(app)) - }), - ); // The window could not be created (no usable GPU / graphics driver, no display): say so // instead of exiting silently, and keep the reason in the crash log. if let Err(e) = &started { @@ -288,6 +160,173 @@ fn main() -> eframe::Result { started } +/// The main window's options; built for each start attempt (they hold a one-shot hook). +fn native_options(agent: bool) -> eframe::NativeOptions { + eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_title("FilmCraft") + .with_inner_size([1600.0, 980.0]) + .with_min_inner_size([900.0, 560.0]) + .with_drag_and_drop(true) + .with_fullsize_content_view(true) + .with_titlebar_shown(false) + .with_title_shown(false) + // Started for an agent (`--control`): open without taking the user's keyboard focus. + .with_active(!agent) + .with_icon(app_icon()) + // Wayland matches the window to `packaging/linux/ai.storyteller.filmcraft.desktop`. + .with_app_id(APP_ID), + event_loop_builder: agent_event_loop(agent), + ..Default::default() + } +} + +/// Creates the app once eframe has its window and graphics device; sets `created` when it runs, +/// and confirms the graphics mode once two frames have painted. +fn app_creator(launch: Launch, graphics: graphics::GraphicsStart, created: Arc) -> eframe::AppCreator<'static> { + let Launch { control_port, files, demo, startup_flag, recover, data_dir } = launch; + Box::new(move |cc| { + created.store(true, Ordering::Relaxed); + let confirmed = AtomicBool::new(false); + cc.egui_ctx.on_end_pass( + "graphics-start", + Arc::new(move |ui| { + if ui.ctx().cumulative_frame_nr() >= 2 && !confirmed.swap(true, Ordering::Relaxed) { + graphics.confirm(); + } + }), + ); + let mut session = Session::default(); + if let Some(dir) = data_dir.clone().or_else(default_data_dir) { + let mut cfg = AutosaveConfig::new(dir); + cfg.local_offset = local_offset; + if let Err(e) = session.start_autosave(cfg) { + log::warn!("auto-save and crash recovery unavailable: {e}"); + } + } + // voice-over recording reads the microphone through cpal + session.voiceover.input = Some(Box::new(audio_in::CpalIn::new(&session.prefs.audio_hardware.device_class))); + let project = files.iter().find(|f| f.ends_with(".fcproj")).cloned(); + if let Some(p) = project { + if let Err(e) = session.execute("file.open", json!({"path": p})) { + log::error!("{e}"); + } + } else if !startup_flag && session.prefs.general.at_startup == "openMostRecent" { + // Settings ▸ General ▸ At Startup ▸ Open Most Recent + let recent = session.prefs.general.recent_projects.iter().find(|p| std::path::Path::new(p).exists()).cloned(); + match recent { + Some(p) => { + if let Err(e) = session.execute("file.open", json!({"path": p})) { + log::error!("{e}"); + } + } + None => { + let _ = session.execute("file.openDemoProject", json!({})); + } + } + } else if !startup_flag && session.prefs.general.at_startup == "emptyProject" { + } else if demo { + let _ = session.execute("file.openDemoProject", json!({})); + } + let media: Vec = files.iter().filter(|f| !f.ends_with(".fcproj")).cloned().collect(); + if !media.is_empty() { + let _ = session.execute("file.import", json!({"paths": media})); + } + if recover == Some(true) && !session.recovery_candidates().is_empty() { + let id = session.recovery_candidates()[0].id.clone(); + match session.execute("file.recover", json!({"id": id})) { + Ok(r) => log::info!("recovered {r}"), + Err(e) => log::error!("recovery failed: {e}"), + } + } + let mut app = FilmcraftApp::new(session); + if recover == Some(false) { + app.dialog = None; + } + app.integrated_titlebar = cfg!(target_os = "macos"); + if let Some(rs) = cc.wgpu_render_state.clone() + && std::env::var_os("FILMCRAFT_CPU_COMPOSITE").is_none() + { + app.set_wgpu(rs); + } + // Keep device selection available even when the default device is unavailable. + // Settings ▸ Audio Hardware is applied on the first frame (`apply_prefs`). + app.audio = Some(Box::new(audio::CpalOut::new())); + app.hooks.pick_files = Some(Box::new(|exts: &[&str]| { + rfd::FileDialog::new() + .add_filter(filmcraft_ui_egui::i18n::t("Media"), &file_filters::extensions(exts)) + .pick_files() + .unwrap_or_default() + .into_iter() + .map(|p| p.to_string_lossy().to_string()) + .collect() + })); + // Link Media ▸ Locate…, Attach Proxies, Reconnect Full Resolution: one path, not imported. + app.hooks.pick_file_for_relink = Some(Box::new(|exts: &[&str], _hint| { + rfd::FileDialog::new() + .add_filter(filmcraft_ui_egui::i18n::t("Media"), &file_filters::extensions(exts)) + .pick_file() + .map(|p| p.to_string_lossy().to_string()) + })); + app.hooks.pick_save = Some(Box::new(|name: &str| { + rfd::FileDialog::new() + .add_filter(filmcraft_ui_egui::i18n::t("FilmCraft Project"), &file_filters::extensions(&["fcproj"])) + .set_file_name(name) + .save_file() + .map(|p| p.to_string_lossy().to_string()) + })); + app.hooks.pick_save_as = Some(Box::new(|filter: &str, exts: &[&str], name: &str| { + rfd::FileDialog::new() + .add_filter(filmcraft_ui_egui::i18n::t(filter), &file_filters::extensions(exts)) + .set_file_name(name) + .save_file() + .map(|p| p.to_string_lossy().to_string()) + })); + app.hooks.pick_folder = Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string()))); + 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()))); + 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())); + // Settings ▸ Appearance ▸ Appearance Mode ▸ Sync with system on Linux desktops whose + // compositor reports no theme to winit (no polling: see appearance.rs). + app.hooks.system_theme = appearance::service(); + app.hooks.raise_without_focus = Some(Box::new(|| { + window_raise::raise_without_focus(); + })); + app.hooks.pick_open_file = Some(Box::new(|filter: &str, exts: &[&str]| { + rfd::FileDialog::new() + .add_filter(filmcraft_ui_egui::i18n::t(filter), &file_filters::extensions(exts)) + .pick_file() + .map(|p| p.to_string_lossy().to_string()) + })); + app.hooks.pick_open_project = Some(Box::new(|| { + rfd::FileDialog::new() + .add_filter(filmcraft_ui_egui::i18n::t("FilmCraft Project"), &file_filters::extensions(&["fcproj"])) + .pick_file() + .map(|p| p.to_string_lossy().to_string()) + })); + #[cfg(target_os = "macos")] + { + let (rx, update) = native_menu::install(&app, cc.egui_ctx.clone()); + app.command_inbox = Some(rx); + app.hooks.shortcuts_changed = Some(update); + app.ui.show_menu_bar = false; + } + if let Some(port) = control_port { + let rx = control_server::start(port, cc.egui_ctx.clone()); + app = app.with_control(rx); + } + Ok(Box::new(app)) + }) +} + /// 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()?; diff --git a/docs/gaps.md b/docs/gaps.md index 83fb12b36..7d3bd17b3 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -79,6 +79,11 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** ## G4. Crashes at start-up, and no test CI on pull requests +- **Have:** a graphics start-up fallback (#820): a start that dies before its window paints (a + driver crash in wgpu instance / device creation) leaves a marker in the data folder, and the next + launch moves on to DirectX 12 only (Windows), then a software adapter (WARP, lavapipe, llvmpipe) + with CPU compositing, and keeps the mode that works; a start-up error wgpu reports moves on in + the same process. An explicit `WGPU_BACKEND` stays in charge. - **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 @@ -87,9 +92,10 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** `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.** +- **Done when:** weak-linked or runtime-loaded OS symbols on old macOS, ~~a software / safe-mode + start when the GPU adapter fails~~ (done, #820; a way back to the default mode from Settings is + still missing: today the marker file is deleted by hand), 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 From f114b5f10c73795e0bd57dbc2220174d293f7542 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 19:01:15 -0400 Subject: [PATCH 147/260] 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 819687549..e74820ab7 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 162c958ab..ed0a928b7 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 cefdfa0db..b78c57735 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 e42db7558..8744da0eb 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 a796742f9..4344cb52f 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 d15928e1f..4fe2357fa 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 d5f1ca9dc..acd2bee9b 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 1b4491887..95f074bb8 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 157e536e6..8acbc1cad 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 74a8e2805..76d0c9ccc 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 28f28f344..e2a41cd77 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 1a6163281..adc0ebb2b 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 62b8b9bd3..4009f3e68 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 8fe1ff6bc..8aaa76551 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 e74879f6d..13bb67a4d 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 a35f48b41..856dad73f 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 d3444534a66f98c883528c2504f8554b9cd103b0 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 10:04:44 +1100 Subject: [PATCH 148/260] Fix Clear on Close Sequence also dropping File Close (Cmd+W). MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File ▸ Close aliases Close Sequence. The shortcut editor targeted sequence.close while Cmd+W lived on file.close (and Timeline), so Clear left the chord bound. Treat the pair as one shortcut family. Fixes storytold/filmcraft#773 --- crates/engine/src/shortcuts.rs | 29 +++++++++++++++++++++++----- crates/engine/src/shortcuts_tests.rs | 20 +++++++++++++++++++ 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/shortcuts.rs b/crates/engine/src/shortcuts.rs index 0c9e47718..b24362989 100644 --- a/crates/engine/src/shortcuts.rs +++ b/crates/engine/src/shortcuts.rs @@ -737,7 +737,7 @@ impl Shortcuts { let nb = Binding { command: command.into(), keys: chord.canonical(), panel: panel.map(str::to_string) }; let before = self.snapshot(); if !add { - self.bindings.retain(|b| !(b.command == command && b.context() == nb.context())); + self.bindings.retain(|b| !(same_shortcut_command(&b.command, command) && b.context() == nb.context())); } let mut reassigned = Vec::new(); if !keep_conflicts { @@ -768,9 +768,19 @@ impl Shortcuts { }; let before = self.snapshot(); let n0 = self.bindings.len(); + let twins = shortcut_twins(command); self.bindings.retain(|b| { - let hit = b.command == command && chord.as_ref().is_none_or(|c| b.chord().as_ref() == Some(c)) && panel.as_ref().is_none_or(|p| &b.panel == p); - !hit + let cmd = same_shortcut_command(&b.command, command); + let key_ok = chord.as_ref().is_none_or(|c| b.chord().as_ref() == Some(c)); + // File ▸ Close and Close Sequence share Cmd+W (app + Timeline). The editor's Clear + // always sends panel Application; treating that as every context for this pair is + // what actually frees the chord (#773). + let panel_ok = match &panel { + None => true, + Some(p) if p.is_none() && twins => true, + Some(p) => &b.panel == p, + }; + !(cmd && key_ok && panel_ok) }); let n = n0 - self.bindings.len(); if n > 0 { @@ -909,12 +919,12 @@ impl Shortcuts { // ---------------------------------------------------------------- queries pub fn for_command(&self, id: &str) -> Vec<&Binding> { - self.bindings.iter().filter(|b| b.command == id).collect() + self.bindings.iter().filter(|b| same_shortcut_command(&b.command, id)).collect() } /// The shortcut menus show: the first application-wide one. pub fn primary(&self, id: &str) -> Option { - self.bindings.iter().find(|b| b.command == id && b.panel.is_none()).map(|b| b.keys.clone()) + self.bindings.iter().find(|b| same_shortcut_command(&b.command, id) && b.panel.is_none()).map(|b| b.keys.clone()) } /// The command a chord runs with `panel` focused (panel shortcuts first). @@ -998,6 +1008,15 @@ fn panel_opt(p: &str) -> Option<&str> { if p.is_empty() { None } else { Some(p) } } +/// `file.close` is File ▸ Close: the same action as `sequence.close` (Close Sequence). +fn shortcut_twins(id: &str) -> bool { + id == "file.close" || id == "sequence.close" +} + +fn same_shortcut_command(bound: &str, target: &str) -> bool { + bound == target || (shortcut_twins(bound) && shortcut_twins(target)) +} + fn check_panel(panel: Option<&str>) -> std::result::Result, String> { match panel { None => Ok(None), diff --git a/crates/engine/src/shortcuts_tests.rs b/crates/engine/src/shortcuts_tests.rs index 9ffd35f4d..c589086d8 100644 --- a/crates/engine/src/shortcuts_tests.rs +++ b/crates/engine/src/shortcuts_tests.rs @@ -113,6 +113,26 @@ fn assign_reassigns_conflicts_and_undoes() { assert!(s.shortcuts.for_command("edit.undo").is_empty()); } +#[test] +fn clear_close_sequence_also_clears_file_close() { + // #773: File ▸ Close (`file.close`, Cmd+W) aliases Close Sequence (`sequence.close`). + // The editor's Clear sends panel Application against `sequence.close`, which had only a + // Timeline binding — Cmd+W stayed on `file.close`. + let mut s = Session::default(); + assert_eq!(s.shortcuts.primary("file.close").as_deref(), Some("Cmd+W")); + assert_eq!(s.shortcuts.primary("sequence.close").as_deref(), Some("Cmd+W")); + assert!(s.shortcuts.for_command("sequence.close").iter().any(|b| b.keys == "Cmd+W")); + let r = s.execute("shortcuts.clear", json!({"command": "sequence.close", "panel": "Application"})).unwrap(); + assert!(r["removed"].as_u64().unwrap() >= 1, "{r}"); + assert!(s.shortcuts.for_command("sequence.close").is_empty(), "{:?}", s.shortcuts.for_command("sequence.close")); + assert!(s.shortcuts.primary("file.close").is_none()); + let w = Chord::parse("Cmd+W").unwrap(); + assert!(s.shortcuts.resolve(&w, None, Platform::current()).is_none()); + assert!(s.shortcuts.resolve(&w, Some("Timeline"), Platform::current()).is_none()); + s.execute("shortcuts.set", json!({"command": "sequence.addEdit", "keys": "Cmd+W", "add": true})).unwrap(); + assert_eq!(s.shortcuts.resolve(&w, None, Platform::current()).unwrap().command, "sequence.addEdit"); +} + #[test] fn panel_shortcuts_override_application_ones_with_focus() { let s = Session::default(); From 03bf8b1f33b099d0638e861bb47397cfb314d262 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 10:09:44 +1100 Subject: [PATCH 149/260] Fix OS drops on an empty Timeline creating a sequence. Dragging files from the explorer onto the Timeline imported them into the Project bin but never placed them when no sequence was open, because only the clip-area interact path consumed pending_timeline_drop. The empty Timeline now creates a sequence from the first imported item and places any further items after it, matching the existing Project-panel drop. --- crates/ui-egui/src/panels/timeline.rs | 20 +++++++++++++ crates/ui-egui/tests/timeline_drop_ui.rs | 37 ++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index ca4fab0dc..adfed455d 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -627,6 +627,26 @@ fn empty_state(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { { let _ = app.session.execute("file.newSequence", json!({"fromItem": item.0})); } + // OS files dropped on an empty Timeline: import already happened; create a sequence from the first item + if let Some((items, p)) = app.pending_timeline_drop.clone() + && rect.contains(p) + && let Some((first, rest)) = items.split_first() + { + app.pending_timeline_drop = None; + if let Err(e) = app.session.execute("file.newSequence", json!({"fromItem": first.0})) { + app.ui.status = e.to_string(); + } else { + let mut t = app.session.project.item(*first).map(|i| i.duration()).unwrap_or(Tick::ZERO); + for item in rest { + let params = json!({"item": item.0, "time": t.0, "insert": false}); + if let Err(e) = app.session.execute("timeline.place", params) { + app.ui.status = e.to_string(); + } else { + t = Tick(t.0.saturating_add(app.session.project.item(*item).map(|i| i.duration().0.max(0)).unwrap_or(0))); + } + } + } + } } /// The through-edit mark: two small triangles pointing at the cut, centred on the track, with a diff --git a/crates/ui-egui/tests/timeline_drop_ui.rs b/crates/ui-egui/tests/timeline_drop_ui.rs index 76bdde9fa..3ad0510cc 100644 --- a/crates/ui-egui/tests/timeline_drop_ui.rs +++ b/crates/ui-egui/tests/timeline_drop_ui.rs @@ -143,6 +143,43 @@ fn tone_dropped_on_a_video_row_goes_to_the_audio_track_without_error() { assert_eq!(clip_counts(&h), (0, 1), "status: {}", h.state().ui.status); } +/// Empty project: no sequence yet. Dropping on the empty Timeline must import *and* make a sequence. +fn empty_harness() -> Harness<'static, FilmcraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).build_eframe(move |_cc| FilmcraftApp::new(Session::default())); + for _ in 0..4 { + step(&mut h); + } + h +} + +fn drop_on_empty_timeline(h: &mut Harness<'static, FilmcraftApp>, path: &Path) { + let panel = control_rect(h, "panel.Timeline"); + pointer_to(h, panel.center()); + h.input_mut().dropped_files.push(std::sync::Arc::new(OsFile(path.to_path_buf()))); + step(h); + h.input_mut().dropped_files.clear(); + step(h); +} + +#[test] +fn still_dropped_on_empty_timeline_creates_a_sequence() { + let mut h = empty_harness(); + assert!(h.state().session.active_sequence().is_none()); + drop_on_empty_timeline(&mut h, &still("drop_still_empty_timeline.png")); + assert_eq!(h.state().session.project.items.len(), 2, "still plus the new sequence"); + assert!(h.state().session.active_sequence().is_some(), "status: {}", h.state().ui.status); + assert_eq!(clip_counts(&h), (1, 0), "status: {}", h.state().ui.status); +} + +#[test] +fn with_drop_import_off_empty_timeline_does_not_create_a_sequence() { + let mut h = empty_harness(); + h.state_mut().session.prefs.timeline.drop_import_to_timeline = false; + drop_on_empty_timeline(&mut h, &still("drop_still_empty_pref_off.png")); + assert_eq!(h.state().session.project.items.len(), 1, "imported to the bin only"); + assert!(h.state().session.active_sequence().is_none()); +} + #[test] fn with_drop_import_off_the_file_is_only_imported() { let mut h = harness(); From f8b37e146373ea5e896d69e55b96a22c76efae6d Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 01:15:53 +0200 Subject: [PATCH 150/260] #831: cargo xtask parity measures effects and transitions (--area) Problem: `cargo xtask parity` (#781, #782) measured only the menu bar; the Effects panel's effects and transitions had no measured parity figure (docs/gaps.md G7). Fix: `--area effects|transitions` compares the catalogue the engine's effect definitions list in the Effects panel (new `effect_catalog` example in filmcraft-project, the panel's own top-folder filter) with a reference of folder paths or bare names (default the local, never committed plan/premiere/.json). Same report, caps and `--min` as menus; placement by folder; Presets / Lumetri Presets and the other area's items are out of scope, so one panel snapshot serves both areas. `--area menus` (default) is unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/project/examples/effect_catalog.rs | 34 ++ docs/gaps.md | 9 +- docs/target-app-parity.md | 4 +- xtask/src/main.rs | 9 +- xtask/src/parity.rs | 403 +++++++++++++++++----- 5 files changed, 370 insertions(+), 89 deletions(-) create mode 100644 crates/project/examples/effect_catalog.rs diff --git a/crates/project/examples/effect_catalog.rs b/crates/project/examples/effect_catalog.rs new file mode 100644 index 000000000..12f17fe78 --- /dev/null +++ b/crates/project/examples/effect_catalog.rs @@ -0,0 +1,34 @@ +//! Print FilmCraft's effect and transition catalog as the Effects panel lists it, as JSON: one +//! object per item with its `id`, display `name`, `kind` (`Video`, `Audio`, `VideoTransition`, +//! `AudioTransition`) and Effects-panel folder `path` (e.g. `["Video Effects", "Blur & Sharpen"]`). +//! Items with no Effects-panel folder (intrinsic Motion / Opacity / Volume…, graphic layers) and +//! the hidden obsolete transitions are left out, as in the panel (the same top-level folder filter +//! as `panels/effects.rs`). `cargo xtask parity --area effects|transitions` reads this to measure +//! coverage against a reference list. +//! +//! ```sh +//! cargo run -p filmcraft-project --example effect_catalog +//! ``` + +use std::process::ExitCode; + +use filmcraft_project::vtransition::EFFECT_TOP_FOLDERS; +use serde_json::json; + +fn main() -> ExitCode { + let items: Vec = filmcraft_project::effect_defs() + .iter() + .filter(|d| d.category.first().is_some_and(|top| EFFECT_TOP_FOLDERS.contains(top))) + .map(|d| json!({"id": d.id, "name": d.name, "kind": format!("{:?}", d.kind), "path": d.category})) + .collect(); + match serde_json::to_string_pretty(&items) { + Ok(text) => { + println!("{text}"); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("effect_catalog: {e}"); + ExitCode::FAILURE + } + } +} diff --git a/docs/gaps.md b/docs/gaps.md index 83fb12b36..015fccae3 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -127,8 +127,13 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** `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`. + `--area effects` and `--area transitions` (#831) measure the Effects panel the same way: the + effects and transitions the engine's effect definitions list (`examples/effect_catalog.rs` in + `filmcraft-project`) against a reference of folder paths or bare names (default the local + `plan/premiere/effects.json` / `transitions.json`), placement by folder, Presets and the other + area out of scope. +- **Missing:** the other areas (commands, 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 diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 13bb67a4d..8c37bb0ff 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -51,7 +51,9 @@ issues now document. That is new evidence, not drift: see [Calibration](#calibra - **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. + effects 53/53, video transitions 84/84 (+21 Legacy), audio transitions 3/3. `cargo xtask + parity --area effects|transitions` measures the shipped catalogue against a reference list too + (presence and folder placement, [G7](gaps.md#g7-no-measured-parity-tool-in-ci)). - **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 diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 1ea276afe..9f61b7c39 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -11,9 +11,10 @@ //! - `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). +//! - `parity [--area menus|effects|transitions] [--reference FILE] [--out FILE] [--min PERCENT]`: +//! measured parity of our shipped menu bar, or of the effects / transitions the Effects panel +//! lists, against a reference list (default: the local `plan/premiere/.json`), presence and +//! placement reported separately (`parity.rs`, docs/gaps.md G7). //! - `ico …`: pack PNGs into a Windows `.ico` (used by `packaging/icons.sh`). //! - `ci`: fmt check, clippy -D warnings, tests, layers, assets, wasm. @@ -500,7 +501,7 @@ fn main() -> ExitCode { 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 index e11a570fd..774a0a76a 100644 --- a/xtask/src/parity.rs +++ b/xtask/src/parity.rs @@ -1,20 +1,31 @@ -//! `cargo xtask parity [--reference FILE] [--ours FILE] [--out FILE] [--min PERCENT]`: measured -//! menu parity (docs/gaps.md G7). +//! `cargo xtask parity [--area menus|effects|transitions] [--reference FILE] [--ours FILE] +//! [--out FILE] [--min PERCENT]`: measured 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. +//! Compares one area of what FilmCraft ships with a reference list, and reports presence and +//! placement separately: a reference item is *same place* when we have its label at the same path, +//! *elsewhere* when we have the label under another path, and *missing* otherwise. A label match is +//! presence only, not behaviour. The areas: //! -//! 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. +//! - `menus` (default): the shipped menu bar (`crates/ui-egui/examples/menu_tree.rs`); paths are +//! menu paths. App, Apple and Help menus, recent-file lists, account / cloud items and macOS +//! system items are counted out of scope. +//! - `effects` / `transitions`: the effects (video and audio) or transitions (video and audio) the +//! Effects panel lists, from the engine's effect definitions +//! (`crates/project/examples/effect_catalog.rs`); paths are Effects-panel folders +//! (`Video Effects > Blur & Sharpen > Gaussian Blur`). A reference entry may also be a bare name, +//! which has no folder and so is matched by name alone. Presets and Lumetri Presets are out of +//! scope, and so are the other area's items (a folder named `… Transitions`), so one reference +//! of the whole Effects panel serves both areas. //! -//! The report goes to `target/parity/parity-checklist.md` (or `--out`); `--min` fails when presence +//! `--ours` reads a saved dump of our side instead of running the example. The reference defaults +//! to the maintainers' local snapshot `plan/premiere/.json` (gitignored, never committed). +//! Without one the tool prints our own inventory and measures nothing. The reference may be any +//! JSON of 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. +//! +//! The report goes to `target/parity/parity-checklist.md` for menus and +//! `target/parity/parity-.md` for the other areas (or `--out`); `--min` fails when presence //! is below the given percentage. use std::collections::{BTreeMap, HashMap, HashSet}; @@ -30,10 +41,99 @@ 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"]; +/// Keys of a wrapper object around the whole menu bar, or the whole effect list. +const MENU_WRAPPER_KEYS: [&str; 5] = ["menus", "menuBar", "menu_bar", "menubar", "items"]; +const EFFECT_WRAPPER_KEYS: [&str; 4] = ["effects", "transitions", "folders", "items"]; + +/// What a parity run measures. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Area { + Menus, + Effects, + Transitions, +} + +impl Area { + fn parse(s: &str) -> Result { + match s { + "menus" => Ok(Area::Menus), + "effects" => Ok(Area::Effects), + "transitions" => Ok(Area::Transitions), + other => Err(format!("--area {other}: expected menus, effects or transitions")), + } + } + + fn name(self) -> &'static str { + match self { + Area::Menus => "menus", + Area::Effects => "effects", + Area::Transitions => "transitions", + } + } + + /// Our side, as the report names it. + fn ours(self) -> &'static str { + match self { + Area::Menus => "shipped menu bar", + Area::Effects => "Effects panel effects", + Area::Transitions => "Effects panel transitions", + } + } + + /// What a path is made of: "menu path" or "folder". + fn place(self) -> &'static str { + match self { + Area::Menus => "menu path", + Area::Effects | Area::Transitions => "folder", + } + } + + fn out_file(self) -> String { + match self { + Area::Menus => "parity-checklist.md".into(), + other => format!("parity-{}.md", other.name()), + } + } + + /// Fewest path parts a reference item has: a menu item needs its menu, an effect may be a + /// bare name. + fn min_len(self) -> usize { + match self { + Area::Menus => 2, + Area::Effects | Area::Transitions => 1, + } + } + + fn wrapper_keys(self) -> &'static [&'static str] { + match self { + Area::Menus => &MENU_WRAPPER_KEYS, + Area::Effects | Area::Transitions => &EFFECT_WRAPPER_KEYS, + } + } + + /// The effect kinds (`EffectKind` names in the catalog dump) this area counts. + fn kinds(self) -> &'static [&'static str] { + match self { + Area::Menus => &[], + Area::Effects => &["Video", "Audio"], + Area::Transitions => &["VideoTransition", "AudioTransition"], + } + } + + /// The group a path is tallied under in the report: the top-level menu, or the folder path. + fn group(self, path: &[String]) -> String { + match self { + Area::Menus => path.first().cloned().unwrap_or_default(), + Area::Effects | Area::Transitions => match path.split_last() { + Some((_, folders)) if !folders.is_empty() => show(folders), + _ => "(no folder)".into(), + }, + } + } +} struct Args { + area: Area, reference: Option, ours: Option, out: Option, @@ -41,11 +141,12 @@ struct Args { } fn parse_args(args: &[&str]) -> Result { - let mut a = Args { reference: None, ours: None, out: None, min: None }; + let mut a = Args { area: Area::Menus, 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 { + "--area" => a.area = Area::parse(&value()?)?, "--reference" => a.reference = Some(value()?.into()), "--ours" => a.ours = Some(value()?.into()), "--out" => a.out = Some(value()?.into()), @@ -59,7 +160,7 @@ fn parse_args(args: &[&str]) -> Result { } other => { return Err(format!( - "parity: unknown argument {other} (usage: cargo xtask parity [--reference FILE] [--ours FILE] [--out FILE] [--min PERCENT])" + "parity: unknown argument {other} (usage: cargo xtask parity [--area menus|effects|transitions] [--reference FILE] [--ours FILE] [--out FILE] [--min PERCENT])" )); } } @@ -69,30 +170,36 @@ fn parse_args(args: &[&str]) -> Result { pub fn run(root: &Path, args: &[&str]) -> Result<(), String> { let args = parse_args(args)?; + let area = args.area; let ours_json = match &args.ours { Some(p) => std::fs::read_to_string(p).map_err(|e| format!("{}: {e}", p.display()))?, - None => dump_ours()?, + None => dump_ours(area)?, }; - 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 ours_value: Value = serde_json::from_str(&ours_json).map_err(|e| format!("our {} dump: {e}", area.name()))?; + let ours = match area { + Area::Menus => our_items(&ours_value)?, + Area::Effects | Area::Transitions => our_catalog(&ours_value, area)?, + }; + let default_ref = root.join(format!("plan/premiere/{}.json", area.name())); 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"); + print!("{}", inventory(area, &ours)); + let a = area.name(); + println!("\nno reference list (plan/premiere/{a}.json, or --reference FILE): {a} parity not measured"); return match args.min { - Some(_) => Err("--min needs a reference menu list".into()), + Some(_) => Err("--min needs a reference 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 paths = reference_paths(&value, area)?; 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")); + let report = compare(area, &paths, &ours); + let out = args.out.unwrap_or_else(|| crate::target_dir().join("parity").join(area.out_file())); if let Some(dir) = out.parent() { std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; } @@ -100,20 +207,25 @@ pub fn run(root: &Path, args: &[&str]) -> Result<(), String> { 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())), + Some(min) if report.presence() < min => Err(format!("{} presence {:.1}% is below --min {min}%", area.name(), report.presence())), _ => Ok(()), } } -/// Our menu bar as JSON, from the `menu_tree` example. -fn dump_ours() -> Result { +/// Our side as JSON: the menu bar from the `menu_tree` example, or the Effects panel catalog from +/// the `effect_catalog` example. +fn dump_ours(area: Area) -> Result { + let (package, example) = match area { + Area::Menus => ("filmcraft-ui-egui", "menu_tree"), + Area::Effects | Area::Transitions => ("filmcraft-project", "effect_catalog"), + }; 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}"))?; + cmd.args(["run", "-q", "-p", package, "--example", example]).stderr(Stdio::inherit()); + let out = cmd.output().map_err(|e| format!("{example}: {e}"))?; if !out.status.success() { - return Err(format!("menu_tree failed: {}", out.status)); + return Err(format!("{example} failed: {}", out.status)); } - String::from_utf8(out.stdout).map_err(|e| format!("menu_tree: {e}")) + String::from_utf8(out.stdout).map_err(|e| format!("{example}: {e}")) } /// Full paths (menu path + label) of our menu items, from the `menu_tree` dump. @@ -130,32 +242,47 @@ fn our_items(v: &Value) -> Result>, String> { .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(); +/// Full paths (Effects-panel folders + name) of the catalog items of `area`, from the +/// `effect_catalog` dump. +fn our_catalog(v: &Value, area: Area) -> Result>, String> { + let items = v.as_array().ok_or("our effect catalog dump is not an array")?; + Ok(items + .iter() + .filter(|it| it.get("kind").and_then(Value::as_str).is_some_and(|k| area.kinds().contains(&k))) + .filter_map(|it| { + let name = it.get("name")?.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(name.to_string()); + Some(path) + }) + .collect()) +} + +/// Item counts per group (top-level menu, or folder), for runs without a reference. +fn inventory(area: Area, ours: &[Vec]) -> String { + let mut per: BTreeMap = BTreeMap::new(); for p in ours { - if let Some(top) = p.first() { - *per.entry(top.as_str()).or_default() += 1; - } + *per.entry(area.group(p)).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")); + let mut s = format!("FilmCraft {}: {} items\n", area.ours(), ours.len()); + let width = per.keys().map(|g| g.chars().count()).max().unwrap_or(0).max(22); + for (group, n) in per { + s.push_str(&format!(" {group: Result>, String> { +/// Leaf paths (top-level menu or folder first, item label last) of a reference file. +fn reference_paths(v: &Value, area: Area) -> 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())) + && let Some(inner) = area.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)?; + walk(root, &[], 0, area.min_len(), &mut out)?; Ok(out) } @@ -169,16 +296,16 @@ fn is_separator(label: &str) -> bool { 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> { +fn push_leaf(prefix: &[String], label: &str, min: usize, 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")); + return Err(format!("reference has more than {MAX_ITEMS} items")); } let mut path = prefix.to_vec(); path.push(label.trim().to_string()); - if path.len() >= 2 { + if path.len() >= min.max(1) { out.push(path); } Ok(()) @@ -189,9 +316,9 @@ fn split_path(s: &str) -> Option> { (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> { +fn walk(v: &Value, prefix: &[String], depth: usize, min: usize, out: &mut Vec>) -> Result<(), String> { if depth > MAX_DEPTH { - return Err(format!("reference menus are nested deeper than {MAX_DEPTH} levels")); + return Err(format!("reference is nested deeper than {MAX_DEPTH} levels")); } let with = |label: &str| { let mut p = prefix.to_vec(); @@ -201,10 +328,10 @@ fn walk(v: &Value, prefix: &[String], depth: usize, out: &mut Vec>) 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), + Some((label, menus)) => push_leaf(menus, label, min, out), None => Ok(()), }, - None => push_leaf(prefix, s, out), + None => push_leaf(prefix, s, min, out), }, Value::Array(a) => { for el in a { @@ -212,10 +339,10 @@ fn walk(v: &Value, prefix: &[String], depth: usize, out: &mut Vec>) 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)?; + push_leaf(menus, label, min, out)?; } } - _ => walk(el, prefix, depth + 1, out)?, + _ => walk(el, prefix, depth + 1, min, out)?, } } Ok(()) @@ -224,17 +351,17 @@ fn walk(v: &Value, prefix: &[String], depth: usize, out: &mut Vec>) 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(c) if !is_separator(label) => walk(c, &with(label), depth + 1, min, out), Some(_) => Ok(()), - None => push_leaf(prefix, label, out), + None => push_leaf(prefix, label, min, out), } } None => { for (k, val) in o { if has_entries(val) { - walk(val, &with(k), depth + 1, out)?; + walk(val, &with(k), depth + 1, min, out)?; } else { - push_leaf(prefix, k, out)?; + push_leaf(prefix, k, min, out)?; } } Ok(()) @@ -259,7 +386,26 @@ fn norm(label: &str) -> String { } /// Why a reference item is not counted, if it isn't. -fn out_of_scope(path: &[String]) -> Option<&'static str> { +fn out_of_scope(area: Area, path: &[String]) -> Option<&'static str> { + match area { + Area::Menus => menu_out_of_scope(path), + Area::Effects | Area::Transitions => { + let top = norm(path.first()?); + if path.len() >= 2 && (top == "presets" || top == "lumetri presets") { + return Some("presets"); + } + let folders = path.get(..path.len().saturating_sub(1)).unwrap_or_default(); + let transition = folders.iter().any(|f| norm(f).ends_with("transitions")); + match area { + Area::Effects if transition => Some("transitions (--area transitions)"), + Area::Transitions if !folders.is_empty() && !transition => Some("effects (--area effects)"), + _ => None, + } + } + } +} + +fn menu_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"); @@ -284,13 +430,14 @@ enum Placement { } struct Report { + area: Area, /// (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 { +fn compare(area: Area, 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(); @@ -299,18 +446,19 @@ fn compare(reference: &[Vec], ours: &[Vec]) -> Report { 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 report = Report { area, 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) { + if let Some(why) = out_of_scope(area, 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)) { + // a bare name (no folder) has no place to compare: found is all it can be + let placement = if our_paths.contains(&key(p)) || (p.len() == 1 && by_label.contains_key(&label)) { Placement::Same } else if let Some(at) = by_label.get(&label) { Placement::Elsewhere((*at).clone()) @@ -349,7 +497,8 @@ impl Report { 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 {}", + "{}: presence {}/{n} ({:.1}%), same place {same}/{n} ({:.1}%), elsewhere {elsewhere}, missing {missing}, out of scope {}", + self.area.name(), same + elsewhere, self.presence(), pct(same, n), @@ -360,17 +509,30 @@ impl Report { 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"); + let area = self.area; + let (title, column) = match area { + Area::Menus => ("Menu", "Menu"), + Area::Effects => ("Effect", "Folder"), + Area::Transitions => ("Transition", "Folder"), + }; + let mut s = format!("# {title} 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::() + "Generated by `cargo xtask parity --area {}` from `{reference}` ({n} items in scope, {} out of scope) against \ + FilmCraft's {} ({ours} items). *Same place*: we have the label at the same {place}; *elsewhere*: under \ + another {place}; *missing*: nowhere. A label match is presence only, not behaviour.{}\n\n", + area.name(), + self.out_of_scope.values().sum::(), + area.ours(), + match area { + Area::Menus => "", + Area::Effects | Area::Transitions => " A bare name with no folder in the reference is matched by name alone.", + }, + place = area.place(), )); - s.push_str("| Menu | Items | Same place | Elsewhere | Missing | Presence |\n|---|---|---|---|---|---|\n"); - let mut per: Vec<(&str, [usize; 3])> = Vec::new(); + s.push_str(&format!("| {column} | Items | Same place | Elsewhere | Missing | Presence |\n|---|---|---|---|---|---|\n")); + let mut per: Vec<(String, [usize; 3])> = Vec::new(); for (p, pl) in &self.items { - let top = p.first().map(String::as_str).unwrap_or_default(); + let top = area.group(p); let i = match per.iter().position(|(t, _)| *t == top) { Some(i) => i, None => { @@ -440,7 +602,7 @@ mod tests { json!({"Edit": {"Undo": "Cmd+Z"}, "File": {"New": ["Sequence…"], "-": null, "Save": null}}), ]; for f in forms { - let mut got = reference_paths(&f).unwrap(); + let mut got = reference_paths(&f, Area::Menus).unwrap(); got.sort(); let mut w = want.clone(); w.sort(); @@ -454,11 +616,15 @@ mod tests { 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")); + for area in [Area::Menus, Area::Effects] { + assert!(reference_paths(&v, area).unwrap_err().contains("nested deeper")); + let many: Vec = (0..MAX_ITEMS + 1).map(|i| format!("File > Item {i}")).collect(); + assert!(reference_paths(&json!(many), area).unwrap_err().contains("more than")); + let bare: Vec = (0..MAX_ITEMS + 1).map(|i| format!("Effect {i}")).collect(); + assert!(area == Area::Menus || reference_paths(&json!(bare), area).unwrap_err().contains("more than")); + } // odd values are skipped, not trusted - assert_eq!(reference_paths(&json!([1, null, true, {"name": 5}, ["File"]])).unwrap(), Vec::>::new()); + assert_eq!(reference_paths(&json!([1, null, true, {"name": 5}, ["File"]]), Area::Menus).unwrap(), Vec::>::new()); } #[test] @@ -474,7 +640,7 @@ mod tests { &["Edit", "Spelling"], ]); let ours = paths(&[&["File", "New", "Sequence…"], &["File", "Export", "Export Frame"], &["Edit", "Find and Replace…"], &["Clip", "Nest…"]]); - let r = compare(&reference, &ours); + let r = compare(Area::Menus, &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); @@ -488,7 +654,7 @@ mod tests { #[test] fn empty_reference_measures_zero_without_dividing_by_zero() { - let r = compare(&[], &paths(&[&["File", "Save"]])); + let r = compare(Area::Menus, &[], &paths(&[&["File", "Save"]])); assert_eq!(r.presence(), 0.0); assert!(r.summary().contains("presence 0/0 (0.0%)")); } @@ -502,6 +668,79 @@ mod tests { 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())); + assert!(parse_args(&["--min", "90", "--reference", "r.json"]).is_ok_and(|a| a.min == Some(90.0) && a.reference.is_some() && a.area == Area::Menus)); + assert!(parse_args(&["--area", "transitions"]).is_ok_and(|a| a.area == Area::Transitions)); + assert!(parse_args(&["--area", "panels"]).is_err()); + assert!(parse_args(&["--area"]).is_err()); + } + + fn catalog() -> Value { + json!([ + {"id": "gaussian_blur", "name": "Gaussian Blur", "kind": "Video", "path": ["Video Effects", "Blur & Sharpen"]}, + {"id": "mosaic", "name": "Mosaic", "kind": "Video", "path": ["Video Effects", "Stylize"]}, + {"id": "volume_x", "name": "Hard Limiter", "kind": "Audio", "path": ["Audio Effects", "Amplitude and Compression"]}, + {"id": "cross_dissolve", "name": "Cross Dissolve", "kind": "VideoTransition", "path": ["Video Transitions", "Dissolve"]}, + {"id": "push_legacy", "name": "Push (Legacy)", "kind": "VideoTransition", "path": ["Legacy", "Video Transitions"]}, + {"id": "constant_power", "name": "Constant Power", "kind": "AudioTransition", "path": ["Audio Transitions", "Crossfade"]}, + {"bad": true}, + {"name": "No kind", "path": []} + ]) + } + + #[test] + fn effect_catalog_splits_into_effects_and_transitions() { + let fx = our_catalog(&catalog(), Area::Effects).unwrap(); + assert_eq!( + fx, + paths(&[ + &["Video Effects", "Blur & Sharpen", "Gaussian Blur"], + &["Video Effects", "Stylize", "Mosaic"], + &["Audio Effects", "Amplitude and Compression", "Hard Limiter"] + ]) + ); + let tr = our_catalog(&catalog(), Area::Transitions).unwrap(); + assert_eq!(tr.len(), 3); + assert!(tr.contains(&paths(&[&["Legacy", "Video Transitions", "Push (Legacy)"]])[0])); + assert!(our_catalog(&json!({"effects": []}), Area::Effects).is_err()); + let inv = inventory(Area::Effects, &fx); + assert!(inv.starts_with("FilmCraft Effects panel effects: 3 items"), "{inv}"); + assert!(inv.contains("Video Effects ▸ Stylize"), "{inv}"); + } + + #[test] + fn effects_reference_of_bare_names_and_folders_is_measured() { + // one reference of the whole Effects panel: folders, bare names, presets, both areas + let reference = json!({"effects": { + "Presets": {"Bevel Edges": ["Bevel Edges Thick"]}, + "Video Effects": {"Blur & Sharpen": ["Gaussian Blur", "Camera Blur"], "Distort": ["Mosaic"]}, + "Video Transitions": {"Dissolve": ["Cross Dissolve", "Film Dissolve"]}, + "Audio Transitions": {"Crossfade": ["Constant Power"]} + }}); + let bare = json!(["Hard Limiter", "Echo"]); + let mut fx_ref = reference_paths(&reference, Area::Effects).unwrap(); + assert_eq!(fx_ref.len(), 7); + fx_ref.extend(reference_paths(&bare, Area::Effects).unwrap()); + // menus need a menu: bare names are not menu items + assert!(reference_paths(&bare, Area::Menus).unwrap().is_empty()); + let ours = our_catalog(&catalog(), Area::Effects).unwrap(); + let r = compare(Area::Effects, &fx_ref, &ours); + // Gaussian Blur same; Mosaic elsewhere (Stylize); Hard Limiter by bare name; Camera Blur, Echo missing + assert_eq!(r.counts(), (2, 1, 2), "{:?}", r.summary()); + assert_eq!(r.out_of_scope.get("presets"), Some(&1)); + assert_eq!(r.out_of_scope.get("transitions (--area transitions)"), Some(&3)); + let md = r.markdown("effects.json", ours.len()); + assert!(md.starts_with("# Effect parity checklist"), "{md}"); + assert!(md.contains("| Folder | Items |"), "{md}"); + assert!(md.contains("| Video Effects ▸ Blur & Sharpen | 2 | 1 | 0 | 1 | 50.0% |"), "{md}"); + assert!(md.contains("| (no folder) | 2 | 1 | 0 | 1 | 50.0% |"), "{md}"); + assert!(md.contains("- Video Effects ▸ Distort ▸ Mosaic → Video Effects ▸ Stylize ▸ Mosaic"), "{md}"); + assert!(md.contains("- [ ] Echo"), "{md}"); + assert!(r.summary().starts_with("effects: presence 3/5 (60.0%)"), "{}", r.summary()); + + let tr_ref = reference_paths(&reference, Area::Transitions).unwrap(); + let tr = compare(Area::Transitions, &tr_ref, &our_catalog(&catalog(), Area::Transitions).unwrap()); + assert_eq!(tr.counts(), (2, 0, 1), "{}", tr.summary()); + assert_eq!(tr.out_of_scope.get("effects (--area effects)"), Some(&3)); + assert!(tr.markdown("effects.json", 3).contains("- [ ] Video Transitions ▸ Dissolve ▸ Film Dissolve")); } } From f79a51dd24b3973d603650fa24bc2abdada28f5e Mon Sep 17 00:00:00 2001 From: MrCloudy2 Date: Sat, 10 Oct 2026 17:14:34 +0200 Subject: [PATCH 151/260] #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. Second version of #662 (reverted): the regression test's stand-in decoder summed with overflow, which panics in debug builds; it now wraps explicitly. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + crates/codecs/Cargo.toml | 1 + crates/codecs/src/gop.rs | 164 +++++++++++++++++++++++++++++++++++-- crates/media/src/cancel.rs | 54 ++++++++++++ 4 files changed, 211 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5529201a2..068d93ea6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1830,6 +1830,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 3a490e2c4..7caf5eb98 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 c1395e430..497683e12 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; @@ -437,6 +438,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 @@ -494,7 +546,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); @@ -616,6 +672,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 = 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 @@ -669,14 +741,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) @@ -761,14 +833,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())) } @@ -900,6 +975,77 @@ 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))) + .reduce(|| 0, u64::wrapping_add); + 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 bfb391dd2..5c2b5f28b 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 199223f3b994128c1c03cd7493dba43295002a5f Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 10:16:50 +1100 Subject: [PATCH 152/260] #697: Paste cut clips from the keyboard even when old OS files linger Copy and Cut snapshot files or an image already on the system clipboard. Paste ignores that same media so Cmd+V and the Edit menu paste the clips instead of re-importing stale files the platform left beside the clip names. --- crates/ui-egui/src/lib.rs | 4 ++ crates/ui-egui/src/menus.rs | 29 +++++++++++- crates/ui-egui/tests/clipboard_paste_ui.rs | 51 ++++++++++++++++++++-- 3 files changed, 79 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5b6208ed6..c1d3cb816 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -322,6 +322,9 @@ pub struct FilmcraftApp { 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, + /// OS files/image that were already on the clipboard when the last Copy/Cut ran. Paste ignores + /// that same media so it pastes the copied clips instead (#697). + pub(crate) stale_clipboard_media: Option, /// 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). @@ -535,6 +538,7 @@ impl FilmcraftApp { tl: Default::default(), command_inbox: None, v_down: false, + stale_clipboard_media: None, gpu: None, watched_render: None, applied_prefs: None, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 1cca0b691..a6b8fea04 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -510,8 +510,9 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: 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. + // Project panel (#611). Copying clips puts their names there as text, so media copied + // *after* them still wins; media that was already there when Copy/Cut ran is ignored so + // Paste of cut/copied clips still works (#697). "edit.paste" | "edit.pasteInsert" if params.as_object().is_none_or(|m| m.is_empty()) && matches!(app.ui.focused, PanelKind::Timeline | PanelKind::Project) => { @@ -556,11 +557,32 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: app.ui.status = e.clone(); } if r.is_ok() && matches!(id, "edit.copy" | "edit.cut") { + remember_stale_clipboard_media(app); put_clip_names_on_system_clipboard(app, ctx); } r } +/// Snapshot OS files/image present at Copy/Cut. Many platforms keep a previous file list after +/// `copy_text`, so Paste would otherwise import those files instead of the clips (#697). +fn remember_stale_clipboard_media(app: &mut FilmcraftApp) { + app.stale_clipboard_media = app.hooks.clipboard_media.as_mut().and_then(|f| f()).as_ref().map(clipboard_media_key); +} + +fn clipboard_media_key(media: &crate::ClipboardMedia) -> String { + match media { + crate::ClipboardMedia::Files(paths) => format!("files:{}", paths.join("\0")), + crate::ClipboardMedia::Image { width, height, rgba } => { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for b in rgba { + h ^= u64::from(*b); + h = h.wrapping_mul(0x0100_0000_01b3); + } + format!("img:{width}x{height}:{h}") + } + } +} + /// Copied clips live in the session, but on Windows and Linux Ctrl+V only reaches the app when the /// system clipboard holds text (an empty clipboard sends no paste at all, #199). Put the copied /// clip names there, like other editors put their own data on the clipboard, so Ctrl+V pastes the @@ -578,6 +600,9 @@ fn put_clip_names_on_system_clipboard(app: &FilmcraftApp, ctx: &egui::Context) { /// 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()?)()?; + if app.stale_clipboard_media.as_ref() == Some(&clipboard_media_key(&media)) { + return None; + } let paths = match media { crate::ClipboardMedia::Files(paths) => paths, crate::ClipboardMedia::Image { width, height, rgba } => match encode_png(width, height, rgba) diff --git a/crates/ui-egui/tests/clipboard_paste_ui.rs b/crates/ui-egui/tests/clipboard_paste_ui.rs index 86beeb73c..c01d2e165 100644 --- a/crates/ui-egui/tests/clipboard_paste_ui.rs +++ b/crates/ui-egui/tests/clipboard_paste_ui.rs @@ -185,12 +185,14 @@ fn without_media_ctrl_v_pastes_copied_clips() { 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": {}})); + d.ok("ui.menu.invoke", json!({"id": "edit.copy"})); let copies = |d: &mut Driver| d.clips().iter().filter(|c| c.0 == 'V' && c.3 == first.3).count(); let n = copies(&mut d); + let asked_after_copy = asked.get(); + assert!(asked_after_copy >= 1, "Copy looks at lingering OS media"); 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); + assert_eq!(asked.get(), asked_after_copy + 1); for pressed in [true, false] { d.harness.input_mut().events.push(egui::Event::Key { key: egui::Key::V, @@ -202,5 +204,48 @@ fn without_media_ctrl_v_pastes_copied_clips() { d.frames(1); } d.frames(2); - assert_eq!((copies(&mut d), asked.get()), (n + 1, 1), "V alone pastes nothing"); + assert_eq!((copies(&mut d), asked.get()), (n + 1, asked_after_copy + 1), "V alone pastes nothing"); +} + +/// Files left on the OS clipboard from before Copy/Cut must not steal Paste: Ctrl+V pastes the +/// cut clip, as the Edit menu does (#697). +#[test] +fn ctrl_v_pastes_a_cut_clip_even_when_old_files_linger_on_the_clipboard() { + let mut d = Driver::demo("cut-linger"); + d.ok("ui.set", json!({"focused": "Timeline"})); + let png = d.dir.join("stale.png"); + image::RgbaImage::from_pixel(8, 8, image::Rgba([0, 0, 200, 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 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("ui.menu.invoke", json!({"id": "edit.cut"})); + assert!(d.clips().iter().all(|c| c.2 != first.2), "the clip left the timeline"); + let named = |d: &mut Driver| d.clips().iter().filter(|c| c.3 == first.3).count(); + assert_eq!(named(&mut d), 0); + d.ctrl_v_with_text(); + assert!(named(&mut d) >= 1, "the cut clip is pasted"); + assert!(!d.clips().iter().any(|c| c.3.contains("stale")), "lingering OS files are not pasted"); +} + +/// Copy from the keyboard (Ctrl+C as `Event::Copy`) also snapshots lingering OS media, so Ctrl+V +/// pastes the clips rather than those files. +#[test] +fn ctrl_c_then_ctrl_v_pastes_clips_not_lingering_files() { + let mut d = Driver::demo("copy-linger"); + d.ok("ui.set", json!({"focused": "Timeline"})); + let png = d.dir.join("stale.png"); + image::RgbaImage::from_pixel(8, 8, 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 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.harness.input_mut().events.extend([egui::Event::ModifiersChanged(egui::Modifiers::COMMAND), egui::Event::Copy]); + d.frames(1); + d.harness.input_mut().events.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + d.frames(2); + assert!(!d.app().session.state.clipboard.is_empty()); + let n = d.clips().iter().filter(|c| c.0 == 'V' && c.3 == first.3).count(); + d.ctrl_v_with_text(); + assert_eq!(d.clips().iter().filter(|c| c.0 == 'V' && c.3 == first.3).count(), n + 1); } From 42b990b72be035562473d6637bbf0a9e4e1af7ad Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 01:26:31 +0200 Subject: [PATCH 153/260] #841: import packed 10/12-bit and Y'CbCr DPX stills Problem: DPX import rejected the 10/12-bit packed layout (packing 0) and the Y'CbCr descriptors (100 CbYCrY, 101 CbYACrYA, 102 CbYCr, 103 CbYCrA) with an "unsupported" error. Fix: unpack packed components back to back, low bits first, each scan line on a 32-bit boundary (as ffmpeg reads 12-bit packed files), and convert Y'CbCr to RGB with the matrix the colorimetric field names (BT.709; BT.601 for the 601 and composite codes; by picture size when unspecified), in video range unless the reference codes span the full code range; 4:2:2 chroma is shared by each pixel pair. Unit tests for every new layout and hostile headers; an ffmpeg oracle test builds packed 12-bit RGB and 8-bit 4:2:2 / 4:4:4 / 4:4:4:4 files from ffmpeg's test picture and matches its decode within one 8-bit step. Co-Authored-By: Claude Opus 5.5 --- crates/codecs/tests/media_oracle.rs | 92 ++++++++++++ crates/media/src/dpx.rs | 216 +++++++++++++++++++++++----- docs/file-format-parity.md | 2 +- docs/gaps.md | 6 +- 4 files changed, 280 insertions(+), 36 deletions(-) diff --git a/crates/codecs/tests/media_oracle.rs b/crates/codecs/tests/media_oracle.rs index 4fc6638e7..da95c5eb6 100644 --- a/crates/codecs/tests/media_oracle.rs +++ b/crates/codecs/tests/media_oracle.rs @@ -171,6 +171,98 @@ fn dpx_stills_every_layout() { } } +/// A big-endian DPX file holding `data` as its one image element (`w` × `h`, the given +/// descriptor, bit depth, packing and colorimetric code; reference codes undefined). +fn dpx_file(w: u32, h: u32, descriptor: u8, bits: u8, packing: u16, colorimetric: u8, data: &[u8]) -> Vec { + let mut f = vec![0u8; 2048]; + let put = |f: &mut Vec, at: usize, v: &[u8]| f[at..at + v.len()].copy_from_slice(v); + put(&mut f, 0, b"SDPX"); + put(&mut f, 4, &2048u32.to_be_bytes()); + put(&mut f, 8, b"V2.0"); + put(&mut f, 16, &(2048 + data.len() as u32).to_be_bytes()); + put(&mut f, 770, &1u16.to_be_bytes()); + put(&mut f, 772, &w.to_be_bytes()); + put(&mut f, 776, &h.to_be_bytes()); + let el = 780; + put(&mut f, el + 4, &u32::MAX.to_be_bytes()); + put(&mut f, el + 12, &u32::MAX.to_be_bytes()); + f[el + 20] = descriptor; + f[el + 22] = colorimetric; + f[el + 23] = bits; + put(&mut f, el + 24, &packing.to_be_bytes()); + put(&mut f, el + 28, &2048u32.to_be_bytes()); + f.extend_from_slice(data); + f +} + +/// `pix_fmt` raw frame of ffmpeg's test picture at `w` × `h`. +fn raw_picture(ff: &Path, w: u32, h: u32, pix_fmt: &str) -> Vec { + let alpha = if pix_fmt.contains('a') { ",format=rgba,geq=r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':a='X*2+Y'" } else { "" }; + let vf = format!("scale={w}:{h}{alpha}"); + ffmpeg_out(ff, &["-f", "lavfi", "-i", "testsrc2=size=96x64", "-frames:v", "1", "-vf", &vf, "-f", "rawvideo", "-pix_fmt", pix_fmt, "-"]) +} + +/// A DPX file we built must import exactly like ffmpeg decodes it, within `tol` of its 16-bit +/// decode rounded to 8 bits. +fn dpx_matches(ff: &Path, name: &str, file: Vec, tol: u8) { + let path = dir().join(name); + std::fs::create_dir_all(dir()).unwrap(); + std::fs::write(&path, &file).unwrap(); + let src = filmcraft_codecs::open_bytes(name, file.into()).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(src.info().kind, MediaKind::Still, "{name}"); + let ours = rgba(&src.video_frame(FrameRequest::full(filmcraft_time::Tick(0))).unwrap()); + let wide = ffmpeg_out(ff, &["-i", path.to_str().unwrap(), "-f", "rawvideo", "-pix_fmt", "rgba64le", "-"]); + let want: Vec = 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}"); + eprintln!("{name}: max diff {d}"); +} + +/// Packed 12-bit RGB (packing 0: components back to back, low bits first, lines on 32-bit +/// boundaries) and 8-bit Y'CbCr (4:2:2, 4:4:4, 4:4:4:4 with alpha; BT.709, BT.601 and unspecified +/// colorimetry), layouts ffmpeg reads but does not write, built here from ffmpeg's test picture +/// (#841). +#[test] +fn dpx_packed_and_ycbcr_match_ffmpeg() { + let ff = filmcraft_testkit::require_ffmpeg!(); + let (w, h) = (95u32, 63u32); + let rgb: Vec = raw_picture(&ff, w, h, "rgb48le").as_chunks::<2>().0.iter().map(|c| u32::from(u16::from_le_bytes(*c)) >> 4).collect(); + let mut data = Vec::new(); + for line in rgb.chunks(w as usize * 3) { + let (mut acc, mut n) = (0u64, 0u32); + for &v in line { + acc |= u64::from(v) << n; + n += 12; + while n >= 32 { + data.extend_from_slice(&(acc as u32).to_be_bytes()); + acc >>= 32; + n -= 32; + } + } + if n > 0 { + data.extend_from_slice(&(acc as u32).to_be_bytes()); + } + } + dpx_matches(&ff, "dpx_packed12.dpx", dpx_file(w, h, 50, 12, 0, 6, &data), 1); + + // Y'CbCr: even width, scan lines a multiple of 4 bytes + let (w, h) = (96u32, 64u32); + let px = (w * h) as usize; + let uyvy = raw_picture(&ff, w, h, "uyvy422"); + let yuv = raw_picture(&ff, w, h, "yuv444p"); + let yuva = raw_picture(&ff, w, h, "yuva444p"); + let interleave = |planes: &[u8], order: &[usize]| -> Vec { (0..px).flat_map(|i| order.iter().map(move |&p| planes[p * px + i])).collect() }; + // planes are Y, U (Cb), V (Cr), A; DPX stores Cb Y Cr A + let cbycr = interleave(&yuv, &[1, 0, 2]); + let cbycra = interleave(&yuva, &[1, 0, 2, 3]); + for colorimetric in [6u8, 7, 0] { + dpx_matches(&ff, &format!("dpx_cbycry_{colorimetric}.dpx"), dpx_file(w, h, 100, 8, 0, colorimetric, &uyvy), 1); + dpx_matches(&ff, &format!("dpx_cbycr_{colorimetric}.dpx"), dpx_file(w, h, 102, 8, 0, colorimetric, &cbycr), 1); + } + dpx_matches(&ff, "dpx_cbycra.dpx", dpx_file(w, h, 103, 8, 0, 6, &cbycra), 1); +} + #[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 index 2d5832faf..474d1236e 100644 --- a/crates/media/src/dpx.rs +++ b/crates/media/src/dpx.rs @@ -1,16 +1,23 @@ //! 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. +//! The first image element is decoded: luma, RGB, RGBA, ABGR, or Y'CbCr (CbYCrY 4:2:2, CbYACrYA +//! 4:2:2:4, CbYCr 4:4:4, CbYCrA 4:4:4:4), 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; packed 10/12-bit components (packing 0) follow each other with no +//! padding, the first in the low bits of a 32-bit word, a component crossing into the next word +//! continuing in its low bits. 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. RGB and luma +//! code values map linearly onto 8 bits (full range); a logarithmic (printing density) transfer is +//! shown as stored, without a film-log conversion. Y'CbCr is converted with the matrix the +//! colorimetric field names (BT.709, or BT.601 for the 601 and composite video codes; by picture +//! size when unspecified), in video range unless the reference low / high data codes span the +//! whole code range; 4:2:2 chroma is shared by the two pixels of a pair. //! -//! 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. +//! Not decoded (an "unsupported" error): run-length encoded elements, 1-bit and floating-point +//! components, transposed orientations. + +use filmcraft_color::{Matrix, Range, normalize_c, normalize_y, ycbcr_to_rgb}; use crate::{MediaError, Result, still::Raster}; @@ -63,6 +70,8 @@ enum Packing { Words { shift: u32 }, /// Three 10-bit components per 32-bit word, the first in the high bits, above `pad` bits. Ten { pad: u32 }, + /// `bits`-bit components back to back in 32-bit words, low bits first (packing 0). + Packed { bits: u32 }, } impl Packing { @@ -73,6 +82,7 @@ impl Packing { Packing::Bytes => comps, Packing::Words { .. } => comps.checked_mul(2)?, Packing::Ten { .. } => comps.div_ceil(3).checked_mul(4)?, + Packing::Packed { bits } => comps.checked_mul(bits as usize)?.div_ceil(32).checked_mul(4)?, }; raw.checked_next_multiple_of(4) } @@ -93,6 +103,69 @@ impl Packing { } } } + Packing::Packed { bits } => { + let mask = (1u64 << bits) - 1; + for i in 0..comps { + let bit = i * bits as usize; + let at = bit / 32 * 4; + let pair = u64::from(order.u32(line, at)) | (u64::from(order.u32(line, at + 4)) << 32); + out.push(((pair >> (bit % 32)) & mask) as u16); + } + } + } + } +} + +/// What the stored components of a pixel are. +#[derive(Clone, Copy)] +enum Components { + /// `n` components per pixel; the indices of R, G, B and A among them (alpha `None`: opaque). + Rgb { n: usize, map: [Option; 4] }, + /// `n` components per pixel: Cb (4:2:2: Cb on even pixels, Cr on odd ones), Y', then Cr (4:4:4 + /// only), then alpha when `alpha`. + YCbCr { n: usize, sub: bool, alpha: bool, matrix: Matrix, range: Range }, +} + +impl Components { + fn per_pixel(self) -> usize { + match self { + Components::Rgb { n, .. } | Components::YCbCr { n, .. } => n, + } + } + + fn has_alpha(self) -> bool { + match self { + Components::Rgb { map, .. } => map[3].is_some(), + Components::YCbCr { alpha, .. } => alpha, + } + } + + /// RGBA8 of pixel `x` from the unpacked component values of its scan line (`bits` per value). + fn pixel(self, line: &[u16], x: usize, bits: u32) -> [u8; 4] { + let max = (1u32 << bits) - 1; + let to8 = |v: u32| ((v.min(max) * 255 + max / 2) / max) as u8; + let get = |i: usize| line.get(i).map(|&v| u32::from(v)); + match self { + Components::Rgb { n, map } => map.map(|slot| match slot { + Some(i) => get(x * n + i).map_or(0, to8), + None => 255, + }), + Components::YCbCr { n, sub, alpha, matrix, range } => { + let base = x * n; + let (cb, cr) = if sub { + let pair = (x & !1) * n; + (get(pair), get(pair + n)) + } else { + (get(base), get(base + 2)) + }; + // a missing Cr (odd width, 4:2:2) is neutral + let mid = 1u32 << (bits - 1); + let y = normalize_y(get(base + 1).unwrap_or(0), bits, range); + let rgb = ycbcr_to_rgb(y, normalize_c(cb.unwrap_or(mid), bits, range), normalize_c(cr.unwrap_or(mid), bits, range), matrix); + let c = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + let a = if alpha { get(base + n - 1).map_or(0, to8) } else { 255 }; + [c(rgb[0]), c(rgb[1]), c(rgb[2]), a] + } } } } @@ -110,7 +183,10 @@ pub fn decode(name: &str, bytes: &[u8]) -> Result { 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 low_code = order.u32(bytes, el + 4); + let high_code = order.u32(bytes, el + 12); let descriptor = bytes.get(el + 20).copied().unwrap_or(0); + let colorimetric = bytes.get(el + 22).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); @@ -120,14 +196,6 @@ pub fn decode(name: &str, bytes: &[u8]) -> Result { 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())); } @@ -138,6 +206,7 @@ pub fn decode(name: &str, bytes: &[u8]) -> Result { (10, 2) => Packing::Ten { pad: 0 }, (12, 1) => Packing::Words { shift: 4 }, (12, 2) => Packing::Words { shift: 0 }, + (10 | 12, 0) => Packing::Packed { bits: u32::from(bits) }, (b, p) => return Err(unsupported(name, format!("{b}-bit components with packing {p}"))), }; let (flip_x, flip_y) = match orientation { @@ -147,6 +216,27 @@ pub fn decode(name: &str, bytes: &[u8]) -> Result { 3 => (true, true), o => return Err(unsupported(name, format!("orientation {o}"))), }; + let max = (1u32 << bits) - 1; + // video range unless the reference codes span every code value + let range = if low_code == 0 && high_code == max { Range::Full } else { Range::Limited }; + let matrix = match colorimetric { + 5 | 6 => Matrix::Bt709, + 7..=10 => Matrix::Bt601, + _ => filmcraft_frame::default_matrix(w as u32, h as u32), + }; + let ycbcr = |n, sub, alpha| Components::YCbCr { n, sub, alpha, matrix, range }; + let components = match descriptor { + 6 => Components::Rgb { n: 1, map: [Some(0), Some(0), Some(0), None] }, + 50 => Components::Rgb { n: 3, map: [Some(0), Some(1), Some(2), None] }, + 51 => Components::Rgb { n: 4, map: [Some(0), Some(1), Some(2), Some(3)] }, + 52 => Components::Rgb { n: 4, map: [Some(3), Some(2), Some(1), Some(0)] }, + 100 => ycbcr(2, true, false), + 101 => ycbcr(3, true, true), + 102 => ycbcr(3, false, false), + 103 => ycbcr(4, false, true), + d => return Err(unsupported(name, format!("descriptor {d}"))), + }; + let n = components.per_pixel(); 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"))?; @@ -157,8 +247,7 @@ pub fn decode(name: &str, bytes: &[u8]) -> Result { // 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 bits = u32::from(bits); let mut rgba = vec![0u8; pixels * 4]; let mut line = Vec::with_capacity(comps); for y in 0..h { @@ -168,19 +257,12 @@ pub fn decode(name: &str, bytes: &[u8]) -> Result { 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; - } + if let Some(px) = rgba.get_mut(o..o + 4) { + px.copy_from_slice(&components.pixel(&line, x, bits)); } } } - let has_alpha = map[3].is_some() && rgba.as_chunks::<4>().0.iter().any(|p| p[3] != 255); + let has_alpha = components.has_alpha() && rgba.as_chunks::<4>().0.iter().any(|p| p[3] != 255); Ok(Raster { width: w as u32, height: h as u32, rgba, has_alpha }) } @@ -250,14 +332,84 @@ mod tests { assert_eq!(r.rgba, [255, 0, 0, 128]); } + /// Packing 0: components back to back, low bits first, each line on a 32-bit boundary. + fn packed(lines: &[&[u32]], bits: u32, big: bool) -> Vec { + let mut out = Vec::new(); + for line in lines { + let (mut acc, mut n) = (0u64, 0u32); + for &v in *line { + acc |= u64::from(v) << n; + n += bits; + while n >= 32 { + let w = acc as u32; + out.extend_from_slice(&if big { w.to_be_bytes() } else { w.to_le_bytes() }); + acc >>= 32; + n -= 32; + } + } + if n > 0 { + let w = acc as u32; + out.extend_from_slice(&if big { w.to_be_bytes() } else { w.to_le_bytes() }); + } + } + out + } + + #[test] + fn packed_ten_and_twelve_bit_components_cross_words() { + // 2 × 2 RGB 10-bit: 6 components (60 bits) per line, the fourth straddles two words + let lines: [&[u32]; 2] = [&[1023, 0, 512, 4, 1019, 1023], &[0, 1023, 0, 1023, 1023, 1023]]; + for big in [true, false] { + let r = decode("a.dpx", &file(big, 2, 2, 50, 10, 0, 0, &packed(&lines, 10, big))).unwrap(); + assert_eq!(r.rgba, [255, 0, 128, 255, 1, 254, 255, 255, 0, 255, 0, 255, 255, 255, 255, 255]); + } + // 3 × 1 RGB 12-bit: 9 components, 108 bits in 4 words + let line: [&[u32]; 1] = [&[4095, 0, 2048, 16, 4079, 0, 0, 0, 4095]]; + let r = decode("a.dpx", &file(true, 3, 1, 50, 12, 0, 0, &packed(&line, 12, true))).unwrap(); + assert_eq!(r.rgba, [255, 0, 128, 255, 1, 254, 0, 255, 0, 0, 255, 255]); + } + + #[test] + fn ycbcr_descriptors_convert_to_rgb() { + // 8-bit CbYCrY, video range, BT.709 (colorimetric 6): black, white, then a red pixel pair + // (the 709 code triple of red is rounded, hence green 1) + let mut f = file(true, 4, 1, 100, 8, 0, 0, &[128, 16, 128, 235, 102, 63, 240, 63]); + f[ELEMENT + 22] = 6; + let r = decode("a.dpx", &f).unwrap(); + assert_eq!(r.rgba, [0, 0, 0, 255, 255, 255, 255, 255, 255, 1, 0, 255, 255, 1, 0, 255]); + assert!(!r.has_alpha); + // red in BT.601 (colorimetric 7): the 601 code triple, rounded + let mut f = file(true, 2, 1, 100, 8, 0, 0, &[90, 81, 240, 81]); + f[ELEMENT + 22] = 7; + assert_eq!(decode("a.dpx", &f).unwrap().rgba, [254, 0, 0, 255, 254, 0, 0, 255]); + // 10-bit CbYCr filled (method A), full range from the reference codes: mid grey + let word = |a: u32, b: u32, c: u32| ((a << 22) | (b << 12) | (c << 2)).to_be_bytes(); + let mut f = file(true, 1, 1, 102, 10, 1, 0, &word(512, 512, 512)); + f[ELEMENT + 4..ELEMENT + 8].copy_from_slice(&0u32.to_be_bytes()); + f[ELEMENT + 12..ELEMENT + 16].copy_from_slice(&1023u32.to_be_bytes()); + assert_eq!(decode("a.dpx", &f).unwrap().rgba, [128, 128, 128, 255]); + // 16-bit CbYCrA: white at half opacity + let v = |x: u16| x.to_be_bytes(); + let data: Vec = [v(32768), v(60160), v(32768), v(32768)].concat(); + let r = decode("a.dpx", &file(true, 1, 1, 103, 16, 0, 0, &data)).unwrap(); + assert_eq!(r.rgba, [255, 255, 255, 128]); + assert!(r.has_alpha); + // 8-bit CbYACrYA, 3 pixels: the last pixel has no Cr and is neutral + let r = decode("a.dpx", &file(true, 3, 1, 101, 8, 0, 0, &[128, 235, 255, 128, 16, 0, 128, 235, 255, 0, 0, 0])).unwrap(); + assert_eq!(r.rgba, [255, 255, 255, 255, 0, 0, 0, 0, 255, 255, 255, 255]); + assert!(r.has_alpha); + } + #[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, 150, 8, 0, 0, &[0; 4])).is_err()); + assert!(decode("a.dpx", &file(true, 1, 1, 50, 10, 3, 0, &[0; 4])).is_err()); + assert!(decode("a.dpx", &file(true, 1, 1, 50, 16, 0, 0, &[0; 4])).is_err()); + assert!(decode("a.dpx", &file(true, 3, 3, 100, 12, 0, 0, &[0; 20])).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()); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 2ac9b655c..74e17680e 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -56,7 +56,7 @@ 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 | 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% | +| **DPX** | read + write | yes: luma / RGB / RGBA, 8–16-bit, filled 10/12-bit, either byte order (#821); packed 10/12-bit, Y'CbCr 4:2:2 / 4:4:4 with or without alpha (#841); no RLE | sequences (10-bit RGB, BT.709; #779) | 90% | | 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% | diff --git a/docs/gaps.md b/docs/gaps.md index f798de682..aa6c4f1c1 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -166,9 +166,9 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** 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, 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). + DPX layout, run-length encoding (Targa and DPX stills and sequences import since #821, packed + 10/12-bit and Y'CbCr DPX since #841, 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 fc855e2b0e12ae9f343539f84db976ae4f4d1b2f Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 01:26:42 +0200 Subject: [PATCH 154/260] #835: workflow acceptance tests through the MCP server, checked by ffprobe Problem: no test did a real editing job end to end. Codec and container oracles exist, but nothing imported phone- or camera-like media, edited it through the command layer and checked the exported file (G6). Fix: apps/filmcraft-cli/tests/workflows.rs drives the `filmcraft-cli mcp` binary over stdio on media generated with ffmpeg into target/fixtures: - a VFR H.264 + AAC MP4: sequence from clip, extract, -6 dB audio gain, title, H.264 MP4 export; - a long-GOP H.264 MKV with AAC + AC-3 tracks: ripple trims, colour matte, centred cross dissolve, save; a new CLI process exports ProRes. ffprobe / ffmpeg check streams, frame count, duration, which source frame lands where, A/V sync across the cut, title pixels, the dissolve mix and audio levels. The tests skip without ffmpeg (testkit oracles). Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + apps/filmcraft-cli/Cargo.toml | 3 + apps/filmcraft-cli/tests/workflows.rs | 488 ++++++++++++++++++++++++++ docs/gaps.md | 6 + 4 files changed, 498 insertions(+) create mode 100644 apps/filmcraft-cli/tests/workflows.rs diff --git a/Cargo.lock b/Cargo.lock index 5529201a2..e2da215a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1797,6 +1797,7 @@ dependencies = [ "filmcraft-platform", "filmcraft-project", "filmcraft-render", + "filmcraft-testkit", "filmcraft-time", "serde_json", "tokio", diff --git a/apps/filmcraft-cli/Cargo.toml b/apps/filmcraft-cli/Cargo.toml index ae3a6f317..218b7f7d7 100644 --- a/apps/filmcraft-cli/Cargo.toml +++ b/apps/filmcraft-cli/Cargo.toml @@ -33,3 +33,6 @@ filmcraft-ogg = { workspace = true } filmcraft-automation = { workspace = true } serde_json = { workspace = true } tokio = { version = "1", features = ["rt-multi-thread", "macros"] } + +[dev-dependencies] +filmcraft-testkit = { workspace = true } diff --git a/apps/filmcraft-cli/tests/workflows.rs b/apps/filmcraft-cli/tests/workflows.rs new file mode 100644 index 000000000..b0f7a57a8 --- /dev/null +++ b/apps/filmcraft-cli/tests/workflows.rs @@ -0,0 +1,488 @@ +//! Workflow acceptance tests (docs/gaps.md G6): real editing jobs done end to end the way an agent +//! does them, through the headless MCP server of the `filmcraft-cli` binary (`filmcraft-cli mcp`, +//! JSON-RPC over stdio) and the CLI itself, on media that looks like what people shoot: +//! +//! - a phone clip: H.264 + AAC in MP4 with a variable frame rate (dropped frames); +//! - a camera clip: long-GOP H.264 (one keyframe) in Matroska with two audio tracks (AAC, AC-3). +//! +//! Each test imports, edits (extract, audio gain, title; ripple trims, colour matte, cross +//! dissolve), exports (H.264 MP4; save the project, reopen it in a new process and export ProRes) +//! and checks the file with the ffprobe / ffmpeg oracles: streams, frame count, duration, which +//! source frame lands where, A/V sync, the title's pixels, the dissolve's mix, audio levels. +//! +//! The media is generated with ffmpeg into `target/fixtures/cli/workflows` at test time (never +//! committed); without ffmpeg the tests skip (`FILMCRAFT_REQUIRE_ORACLES=1` makes that a failure). +//! ffmpeg and ffprobe are external test oracles only. + +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; + +use serde_json::{Value, json}; + +// --------------------------------------------------------------------------------------------- +// Fixtures + +/// A phone-like clip: 4 s of 320×240 `testsrc2` at 30 fps with every frame `n % 7 == 3` dropped +/// (variable frame rate, the timestamps of the kept frames unchanged), H.264 + AAC stereo. The +/// whole picture flashes white on the frames at 1.0 s and 3.0 s, and a 1 kHz beep sounds from 1.0 +/// to 1.1 s and from 3.0 to 3.1 s (silence otherwise): A/V sync markers. +fn phone_vfr_mp4(ffmpeg: &Path) -> Option { + let out = filmcraft_testkit::fixtures_dir("cli/workflows").join("phone_vfr_h264_aac.mp4"); + filmcraft_testkit::fixtures::generate(&out, |tmp| { + Command::new(ffmpeg) + .args(["-v", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=320x240:rate=30:duration=4"]) + .args(["-f", "lavfi", "-i", "sine=frequency=1000:sample_rate=48000:duration=4"]) + .args([ + "-filter_complex", + "[0:v]drawbox=x=0:y=0:w=iw:h=ih:color=white:t=fill:enable='between(t,1,1.02)+between(t,3,3.02)',\ + select='not(eq(mod(n,7),3))'[v];\ + [1:a]volume='if(between(t,1,1.1)+between(t,3,3.1),1,0)':eval=frame[a]", + ]) + .args(["-map", "[v]", "-map", "[a]", "-c:v", "libx264", "-preset", "veryfast", "-pix_fmt", "yuv420p", "-fps_mode", "passthrough"]) + .args(["-video_track_timescale", "30000", "-c:a", "aac", "-b:a", "128k", "-ac", "2"]) + .arg(tmp) + .status() + .is_ok_and(|s| s.success()) + }) +} + +/// A camera-like clip: 6 s of 320×240 `testsrc2` at 25 fps as long-GOP H.264 (a single keyframe, +/// B-frames) in Matroska, with two stereo audio tracks: 440 Hz in AAC and 1320 Hz in AC-3. +fn camera_long_gop_mkv(ffmpeg: &Path) -> Option { + let out = filmcraft_testkit::fixtures_dir("cli/workflows").join("camera_long_gop_2audio.mkv"); + filmcraft_testkit::fixtures::generate(&out, |tmp| { + Command::new(ffmpeg) + .args(["-v", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=320x240:rate=25:duration=6"]) + .args(["-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:duration=6"]) + .args(["-f", "lavfi", "-i", "sine=frequency=1320:sample_rate=48000:duration=6"]) + .args(["-map", "0", "-map", "1", "-map", "2", "-c:v", "libx264", "-preset", "veryfast", "-pix_fmt", "yuv420p"]) + .args(["-g", "300", "-keyint_min", "300", "-sc_threshold", "0", "-bf", "3"]) + .args(["-c:a:0", "aac", "-ac:a:0", "2", "-c:a:1", "ac3", "-ac:a:1", "2"]) + .arg(tmp) + .status() + .is_ok_and(|s| s.success()) + }) +} + +/// A scratch directory under the workspace `target/`, removed when dropped. +struct Scratch(PathBuf); + +impl Scratch { + fn new(name: &str) -> Self { + let d = filmcraft_testkit::workspace_root().join("target").join("workflow-tests").join(format!("{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + Scratch(d) + } + fn path(&self, name: &str) -> String { + self.0.join(name).to_string_lossy().into_owned() + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +// --------------------------------------------------------------------------------------------- +// A minimal MCP client for `filmcraft-cli mcp` (newline-delimited JSON-RPC over stdio) + +struct Mcp { + child: Child, + stdin: ChildStdin, + stdout: BufReader, + next_id: u64, +} + +impl Mcp { + /// Start the headless server (an empty project) and initialise the session. `data_dir` keeps + /// user presets away from the real data directory. + fn start(data_dir: &str) -> Self { + let mut child = Command::new(env!("CARGO_BIN_EXE_filmcraft-cli")) + .args(["--data-dir", data_dir, "mcp"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn filmcraft-cli mcp"); + let stdin = child.stdin.take().unwrap(); + let stdout = BufReader::new(child.stdout.take().unwrap()); + let mut m = Mcp { child, stdin, stdout, next_id: 1 }; + let init = + m.request("initialize", json!({"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "workflow-tests", "version": "1"}})); + assert_eq!(init["result"]["serverInfo"]["name"], "filmcraft", "{init}"); + m.send(&json!({"jsonrpc": "2.0", "method": "notifications/initialized"})); + m + } + + fn send(&mut self, msg: &Value) { + writeln!(self.stdin, "{msg}").unwrap(); + self.stdin.flush().unwrap(); + } + + /// Send a request and return its response (notifications in between are skipped). + fn request(&mut self, method: &str, params: Value) -> Value { + let id = self.next_id; + self.next_id += 1; + self.send(&json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})); + loop { + let mut line = String::new(); + assert!(self.stdout.read_line(&mut line).unwrap() > 0, "the MCP server closed stdout while `{method}` was pending"); + let v: Value = serde_json::from_str(&line).unwrap_or_else(|e| panic!("not JSON-RPC: {e}: {line}")); + if v["id"] == id { + return v; + } + } + } + + /// Call a tool; its text result parsed as JSON. Panics on a protocol or tool error. + fn tool(&mut self, name: &str, arguments: Value) -> Value { + let r = self.request("tools/call", json!({"name": name, "arguments": arguments})); + let text = r["result"]["content"][0]["text"].as_str().unwrap_or_default().to_string(); + assert!(r["error"].is_null() && r["result"]["isError"] == false, "{name} {arguments}: {r}"); + serde_json::from_str(&text).unwrap_or(Value::String(text)) + } + + /// `command_run`: one engine command by id. + fn run(&mut self, id: &str, params: Value) -> Value { + self.tool("command_run", json!({"id": id, "params": params})) + } +} + +impl Drop for Mcp { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +// --------------------------------------------------------------------------------------------- +// Oracles + +fn probe(ffprobe: &Path, path: &str) -> Value { + let out = Command::new(ffprobe).args(["-v", "error", "-of", "json", "-count_frames", "-show_format", "-show_streams", path]).output().unwrap(); + assert!(out.status.success(), "ffprobe {path}: {}", String::from_utf8_lossy(&out.stderr)); + serde_json::from_slice(&out.stdout).unwrap() +} + +fn streams<'a>(j: &'a Value, kind: &str) -> Vec<&'a Value> { + j["streams"].as_array().unwrap().iter().filter(|s| s["codec_type"] == kind).collect() +} + +fn num(v: &Value) -> f64 { + v.as_str().and_then(|s| s.parse().ok()).or_else(|| v.as_f64()).unwrap_or(f64::NAN) +} + +/// Run ffmpeg decoding `input` (with `pre` input options) to raw data on stdout. +fn ffmpeg_raw(ffmpeg: &Path, pre: &[&str], input: &str, args: &[&str]) -> Vec { + let out = Command::new(ffmpeg).args(["-v", "error"]).args(pre).args(["-i", input]).args(args).arg("-").output().unwrap(); + assert!(out.status.success(), "ffmpeg {input}: {}", String::from_utf8_lossy(&out.stderr)); + out.stdout +} + +/// Size of the frames [`frames`] returns. +const FW: usize = 80; +const FH: usize = 60; + +/// Every video frame of `input`, scaled to 80×60 in `pix_fmt` (`gray` or `rgb24`). `vf` goes +/// first in the filter chain (e.g. `fps=30` to see a VFR file as an editor shows it). +fn frames(ffmpeg: &Path, input: &str, vf: Option<&str>, pix_fmt: &str) -> Vec> { + let chain = match vf { + Some(f) => format!("{f},scale={FW}:{FH}:flags=area,format={pix_fmt}"), + None => format!("scale={FW}:{FH}:flags=area,format={pix_fmt}"), + }; + let bpp = if pix_fmt == "gray" { 1 } else { 3 }; + let raw = ffmpeg_raw(ffmpeg, &[], input, &["-map", "0:v:0", "-vf", &chain, "-fps_mode", "passthrough", "-f", "rawvideo"]); + raw.chunks_exact(FW * FH * bpp).map(<[u8]>::to_vec).collect() +} + +/// Mean absolute difference of two equally sized frames. +fn diff(a: &[u8], b: &[u8]) -> f64 { + a.iter().zip(b).map(|(x, y)| f64::from(x.abs_diff(*y))).sum::() / a.len().max(1) as f64 +} + +/// Assert that `got` shows source frame `want`: it is (one of) the closest source frames, and close. +fn assert_shows(got: &[u8], src: &[Vec], want: usize, what: &str) { + let d: Vec = src.iter().map(|s| diff(got, s)).collect(); + let best = d.iter().copied().fold(f64::INFINITY, f64::min); + let at = d.iter().position(|x| *x == best).unwrap_or(0); + assert!(d[want] < 6.0 && d[want] <= best + 0.25, "{what}: expected source frame {want} (diff {:.2}), closest is {at} (diff {best:.2})", d[want]); +} + +/// The first channel of audio stream `stream` of `input`, mono-mixed, as f32 at 48 kHz. +fn pcm(ffmpeg: &Path, input: &str, stream: usize) -> Vec { + let map = format!("0:a:{stream}"); + let raw = ffmpeg_raw(ffmpeg, &[], input, &["-map", &map, "-ac", "1", "-ar", "48000", "-f", "f32le"]); + raw.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)).collect() +} + +/// Times (s) where the signal rises above a quarter of its peak, at least 0.2 s apart. +fn onsets(x: &[f32]) -> Vec { + let peak = x.iter().fold(0f32, |m, v| m.max(v.abs())); + let mut out = Vec::new(); + let mut last = f64::NEG_INFINITY; + for (i, block) in x.chunks(48).enumerate() { + let t = i as f64 * 48.0 / 48_000.0; + if block.iter().any(|v| v.abs() > peak / 4.0) && t - last > 0.2 { + out.push(t); + } + if block.iter().any(|v| v.abs() > peak / 4.0) { + last = t; + } + } + out +} + +fn rms(x: &[f32], from: f64, to: f64) -> f64 { + let s = x.get((from * 48_000.0) as usize..((to * 48_000.0) as usize).min(x.len())).unwrap_or_default(); + (s.iter().map(|v| f64::from(*v) * f64::from(*v)).sum::() / s.len().max(1) as f64).sqrt() +} + +/// Amplitude of the `freq` component of 100 ms of `x` from `at` seconds (Goertzel). +fn tone(x: &[f32], at: f64, freq: f64) -> f64 { + let start = (at * 48_000.0) as usize; + let s = x.get(start..(start + 4800).min(x.len())).unwrap_or_default(); + let c = 2.0 * (2.0 * std::f64::consts::PI * freq / 48_000.0).cos(); + let (mut s1, mut s2) = (0.0, 0.0); + for v in s { + let s0 = f64::from(*v) + c * s1 - s2; + s2 = s1; + s1 = s0; + } + (s1 * s1 + s2 * s2 - c * s1 * s2).max(0.0).sqrt() * 2.0 / s.len().max(1) as f64 +} + +fn db(ratio: f64) -> f64 { + 20.0 * ratio.log10() +} + +fn clips_on<'a>(seq: &'a Value, kind: &str, track: usize) -> &'a Vec { + seq[kind][track]["items"].as_array().unwrap() +} + +// --------------------------------------------------------------------------------------------- +// Workflows + +/// Phone footage: import a VFR H.264/AAC clip, make a sequence from it, extract a second from the +/// middle (Mark In / Mark Out / Extract), lower the audio 6 dB, put a title over the start, and +/// export H.264 MP4 through MCP. The VFR clip plays at its 30 fps base rate (a dropped frame +/// holds the one before), the cut lands on the right frames, picture and sound stay in sync +/// across the cut, the title is in the picture only while it is on the timeline. +#[test] +fn phone_vfr_clip_extract_gain_title_to_h264_mp4() { + let (ffmpeg, ffprobe) = filmcraft_testkit::require_oracles!(); + let Some(src) = phone_vfr_mp4(&ffmpeg) else { + return filmcraft_testkit::skip("phone workflow", "ffmpeg could not make the VFR H.264/AAC fixture (libx264?)"); + }; + let src = src.to_string_lossy().into_owned(); + let dir = Scratch::new("phone"); + let mut m = Mcp::start(&dir.path("data")); + + let imported = m.tool("media_import", json!({"paths": [src]})); + assert_eq!(imported["errors"], json!([]), "{imported}"); + let item = imported["items"][0].as_u64().unwrap(); + let made = m.run("file.newSequenceFromClip", json!({"items": [item]})); + assert!(made["sequence"].is_u64(), "{made}"); + let seq = m.tool("sequence_inspect", json!({})); + assert_eq!((seq["settings"]["width"].as_u64(), seq["settings"]["height"].as_u64()), (Some(320), Some(240)), "{}", seq["settings"]); + assert_eq!(seq["settings"]["frame_rate"], json!({"num": 30, "den": 1}), "a VFR phone clip makes a sequence at its base rate"); + assert_eq!(seq["durationFrames"], 120, "4 s at 30 fps"); + + // Extract 1.5 s .. 2.5 s; Mark Out includes its frame, so 31 frames go and 89 remain. + m.run("markers.markIn", json!({"seconds": 1.5})); + m.run("markers.markOut", json!({"seconds": 2.5})); + m.run("sequence.extract", json!({})); + m.run("edit.selectAll", json!({})); + let gain = m.run("clip.audioGain", json!({"mode": "set", "db": -6.0})); + assert_eq!(gain["clips"], 2, "both audio pieces: {gain}"); + m.run("edit.deselectAll", json!({})); + m.run("graphics.newText", json!({"text": "HELLO", "size": 40, "time": 0, "seconds": 0.5})); + let seq = m.tool("sequence_inspect", json!({})); + assert_eq!(seq["durationFrames"], 89, "{seq}"); + assert_eq!(clips_on(&seq, "video", 0).len(), 2); + assert_eq!(clips_on(&seq, "audio", 0).len(), 2); + + let out = dir.path("phone_edit.mp4"); + let r = m.run("file.exportMedia", json!({"path": out, "format": "h264", "range": "entire", "wait": true})); + assert_eq!(r["result"]["frames"], 89, "{r}"); + + // container and streams + let j = probe(&ffprobe, &out); + let (v, a) = (streams(&j, "video"), streams(&j, "audio")); + assert_eq!((v.len(), a.len()), (1, 1), "{j}"); + assert_eq!((v[0]["codec_name"].as_str(), v[0]["width"].as_u64(), v[0]["height"].as_u64()), (Some("h264"), Some(320), Some(240))); + assert_eq!(v[0]["r_frame_rate"], "30/1"); + assert_eq!(v[0]["nb_read_frames"], "89"); + assert_eq!((a[0]["codec_name"].as_str(), a[0]["sample_rate"].as_str(), a[0]["channels"].as_u64()), (Some("aac"), Some("48000"), Some(2))); + let duration = num(&j["format"]["duration"]); + assert!((duration - 89.0 / 30.0).abs() < 0.06, "duration {duration}"); + assert!((num(&a[0]["duration"]) - 89.0 / 30.0).abs() < 0.06, "audio as long as the picture: {}", a[0]["duration"]); + + // picture: frame k of the export is frame k (before the cut) or k + 31 (after) of the source + // as an editor plays it (CFR 30, a dropped frame holding the previous one) + let src_frames = frames(&ffmpeg, &src, Some("fps=30"), "gray"); + let got = frames(&ffmpeg, &out, None, "gray"); + assert!(src_frames.len() >= 120, "{} source frames", src_frames.len()); + assert_eq!(got.len(), 89); + for k in [20, 32, 40, 44, 45, 50, 70, 88] { + let want = if k < 45 { k } else { k + 31 }; + assert_shows(&got[k], &src_frames, want, &format!("export frame {k}")); + } + // the flash frames: 30 (with 31 holding it, as the source dropped its frame 31) and 90 - 31 + let mean = |f: &Vec| f.iter().map(|v| f64::from(*v)).sum::() / f.len() as f64; + let flashes: Vec = got.iter().enumerate().filter(|(_, f)| mean(f) > 200.0).map(|(i, _)| i).collect(); + assert_eq!(flashes, [30, 31, 59], "white frames"); + + // the title: over frames 0..15 only (its pixels differ from the source there, nowhere after) + let blocks_changed = |k: usize| { + let mut n = 0; + for by in (0..FH).step_by(10) { + for bx in (0..FW).step_by(10) { + let mut d = 0.0; + for y in by..by + 10 { + for x in bx..bx + 10 { + d += f64::from(got[k][y * FW + x].abs_diff(src_frames[k][y * FW + x])); + } + } + n += usize::from(d / 100.0 > 15.0); + } + } + n + }; + for k in [2, 8, 14] { + assert!(blocks_changed(k) >= 2, "title missing on frame {k}"); + } + for k in [16, 25] { + assert_eq!(blocks_changed(k), 0, "title still on frame {k}"); + } + + // sound: beeps where the flashes are (A/V sync across the extract), 6 dB down + let src_pcm = pcm(&ffmpeg, &src, 0); + let got_pcm = pcm(&ffmpeg, &out, 0); + let beeps = onsets(&got_pcm); + assert_eq!(beeps.len(), 2, "beeps at {beeps:?}"); + for (beep, flash) in beeps.iter().zip([30.0 / 30.0, 59.0 / 30.0]) { + assert!((beep - flash).abs() <= 1.0 / 30.0, "beep at {beep:.3} s, flash at {flash:.3} s"); + } + let change = db(rms(&got_pcm, 1.01, 1.09) / rms(&src_pcm, 1.01, 1.09)); + assert!((change + 6.0).abs() < 1.0, "audio gain -6 dB measured {change:.2} dB"); + let change = db(rms(&got_pcm, 1.98, 2.06) / rms(&src_pcm, 3.01, 3.09)); + assert!((change + 6.0).abs() < 1.0, "second beep {change:.2} dB"); +} + +/// Camera footage: import a long-GOP H.264 Matroska file with two audio tracks through MCP, make +/// a sequence (both tracks land on A1 and A2), ripple-trim 2.6 s off the head (decoding starts at +/// the only keyframe) and 15 frames off the tail, add a red colour matte with a centred cross +/// dissolve, save the project; then a new `filmcraft-cli` process opens it and exports ProRes. +/// The first exported frame is source frame 65, the dissolve mixes the two pictures, both audio +/// tracks are in the mix at their own level and stop where the clip does. +#[test] +fn camera_long_gop_two_audio_tracks_trim_dissolve_to_prores() { + let (ffmpeg, ffprobe) = filmcraft_testkit::require_oracles!(); + let Some(src) = camera_long_gop_mkv(&ffmpeg) else { + return filmcraft_testkit::skip("camera workflow", "ffmpeg could not make the long-GOP Matroska fixture (libx264?)"); + }; + let src = src.to_string_lossy().into_owned(); + let dir = Scratch::new("camera"); + let data = dir.path("data"); + let project = dir.path("camera.fcproj"); + { + let mut m = Mcp::start(&data); + let imported = m.tool("media_import", json!({"paths": [src]})); + assert_eq!(imported["errors"], json!([]), "{imported}"); + let item = imported["items"][0].as_u64().unwrap(); + let made = m.run("file.newSequenceFromClip", json!({"items": [item]})); + let clips: Vec = made["clips"].as_array().unwrap().iter().filter_map(Value::as_u64).collect(); + assert_eq!(clips.len(), 3, "a video clip and one clip per audio track: {made}"); + let seq = m.tool("sequence_inspect", json!({})); + assert_eq!(seq["settings"]["frame_rate"], json!({"num": 25, "den": 1})); + assert_eq!(seq["audio"].as_array().map(Vec::len), Some(2), "two audio tracks"); + let video = clips_on(&seq, "video", 0)[0]["clip"].as_u64().unwrap(); + + // 65 frames off the head and 15 off the tail, the linked audio following + m.run("timeline.trim", json!({"clip": video, "edge": "in", "mode": "ripple", "deltaFrames": 65})); + m.run("timeline.trim", json!({"clip": video, "edge": "out", "mode": "ripple", "deltaFrames": -15})); + let seq = m.tool("sequence_inspect", json!({})); + let end = clips_on(&seq, "video", 0)[0]["endFrame"].as_u64().unwrap(); + for t in 0..2 { + let a = &clips_on(&seq, "audio", t)[0]; + assert_eq!((a["startFrame"].as_u64(), a["endFrame"].as_u64(), a["sourceIn"].as_i64()), (Some(0), Some(end), Some(660_441_600_000)), "A{}", t + 1); + } + + let matte = m.run("file.newColorMatte", json!({"color": "#ff0000", "seconds": 2.0}))["item"].as_u64().unwrap(); + let placed = m.run("timeline.place", json!({"item": matte, "track": "V1", "frame": end, "insert": false})); + let matte_clip = placed["clips"][0].as_u64().unwrap(); + m.run("sequence.applyVideoTransition", json!({"clip": matte_clip, "edge": "in", "frames": 10, "effect": "cross_dissolve"})); + let seq = m.tool("sequence_inspect", json!({})); + let tr = &seq["video"][0]["transitions"][0]; + assert_eq!((tr["effect"].as_str(), tr["align"].as_str()), (Some("cross_dissolve"), Some("center")), "{tr}"); + m.run("file.saveAs", json!({"path": project})); + } + assert_eq!(end_frame_of(&project), Some(71), "the saved project holds the edit"); + + // a fresh process opens the saved project and renders it + let out = dir.path("camera_edit.mov"); + let o = Command::new(env!("CARGO_BIN_EXE_filmcraft-cli")).args(["--data-dir", &data, "--project", &project, "export", &out]).output().unwrap(); + assert!(o.status.success(), "export: {}", String::from_utf8_lossy(&o.stderr)); + let r: Value = serde_json::from_slice(&o.stdout).unwrap(); + let total = 71 + 50; + assert_eq!(r["result"]["frames"], total, "{r}"); + + let j = probe(&ffprobe, &out); + let (v, a) = (streams(&j, "video"), streams(&j, "audio")); + assert_eq!((v.len(), a.len()), (1, 1), "{j}"); + assert_eq!((v[0]["codec_name"].as_str(), v[0]["width"].as_u64(), v[0]["height"].as_u64()), (Some("prores"), Some(320), Some(240))); + assert_eq!(v[0]["r_frame_rate"], "25/1"); + assert_eq!(v[0]["nb_read_frames"], total.to_string()); + assert_eq!((a[0]["sample_rate"].as_str(), a[0]["channels"].as_u64()), (Some("48000"), Some(2))); + let duration = num(&j["format"]["duration"]); + assert!((duration - f64::from(total) / 25.0).abs() < 0.05, "duration {duration}"); + + // picture: frame k is source frame 65 + k up to the dissolve (frames 66..76), red after it + let src_frames = frames(&ffmpeg, &src, None, "rgb24"); + let got = frames(&ffmpeg, &out, None, "rgb24"); + assert_eq!((src_frames.len(), got.len()), (150, total as usize)); + for k in [0, 1, 2, 30, 64] { + assert_shows(&got[k], &src_frames, 65 + k, &format!("export frame {k}")); + } + let red = &got[100]; + assert!(red.as_chunks::<3>().0.iter().all(|[r, g, b]| *r > 230 && *g < 25 && *b < 25), "the matte is red"); + // the dissolve: frame = (1 - a)·camera + a·red, `a` rising through about 0.5 at the cut + let mix = |k: usize| { + let (cam, e) = (&src_frames[65 + k], &got[k]); + let (mut num, mut den) = (0.0, 0.0); + for ((c, r), e) in cam.iter().zip(red).zip(e) { + let (c, r, e) = (f64::from(*c), f64::from(*r), f64::from(*e)); + num += (e - c) * (r - c); + den += (r - c) * (r - c); + } + num / den + }; + let alphas: Vec = (64..=78).map(mix).collect(); + assert!(alphas[0].abs() < 0.05 && (alphas[14] - 1.0).abs() < 0.05, "outside the dissolve: {alphas:?}"); + assert!(alphas.windows(2).all(|w| w[1] >= w[0] - 0.02), "the dissolve progresses: {alphas:?}"); + assert!((alphas[7] - 0.5).abs() < 0.12, "half way at the cut (frame 71): {alphas:?}"); + + // sound: both tracks in the mix at their own level while the clip plays, silence on the matte + let got_pcm = pcm(&ffmpeg, &out, 0); + let (t1, t2) = (pcm(&ffmpeg, &src, 0), pcm(&ffmpeg, &src, 1)); + for at in [0.2, 1.5, 2.6] { + let (low, high) = (tone(&got_pcm, at, 440.0), tone(&got_pcm, at, 1320.0)); + let (want_low, want_high) = (tone(&t1, at + 2.6, 440.0), tone(&t2, at + 2.6, 1320.0)); + assert!( + db(low / want_low).abs() < 1.0 && db(high / want_high).abs() < 1.0, + "at {at} s: 440 Hz {low:.4} (want {want_low:.4}), 1320 Hz {high:.4} (want {want_high:.4})" + ); + } + assert!(rms(&got_pcm, 3.0, 4.8) < 1e-3, "silence under the matte"); +} + +/// `endFrame` of the first clip on V1 of the saved project's active sequence, read back through +/// `filmcraft-cli --project … inspect sequence`. +fn end_frame_of(project: &str) -> Option { + let o = Command::new(env!("CARGO_BIN_EXE_filmcraft-cli")).args(["--project", project, "inspect", "sequence"]).output().ok()?; + let v: Value = serde_json::from_slice(&o.stdout).ok()?; + v["video"][0]["items"][0]["endFrame"].as_u64() +} diff --git a/docs/gaps.md b/docs/gaps.md index 83fb12b36..bf456e94e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -110,6 +110,12 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** ## G6. No real-world media corpus or workflow acceptance tests +- **Done:** first scripted MCP workflows (#835, `apps/filmcraft-cli/tests/workflows.rs`): the + `filmcraft-cli mcp` binary imports ffmpeg-made phone-like VFR H.264 + AAC MP4 and long-GOP + H.264 Matroska with two audio tracks (AAC, AC-3), edits (extract, audio gain, title; ripple + trims, colour matte, cross dissolve), exports H.264 MP4, and saves a project that a new process + exports as ProRes; ffprobe / ffmpeg check streams, frame count, duration, frame placement, A/V + sync, title pixels, dissolve mix and audio levels. Skipped without ffmpeg. - **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 From 2ff3f5e266838700c292a35aaf762e3d4dcef980 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 19:28:41 -0400 Subject: [PATCH 155/260] 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 08eba22b6..0ebde2f4a 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 5529201a2..1db021333 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 1d306cffa..a8990aa7b 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 e74820ab7..0ce67cb3e 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 29b65c4c4..52531d0ec 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 631e2ee96..58c283743 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 b2d00bc39..5001bf716 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" From 4a3b2a20f19e4a066587508fbf8f415fd658a02f Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 01:39:41 +0200 Subject: [PATCH 156/260] #840: restart an audio stream the device drops instead of playing silent Problem: the cpal error callback marked the stream failed on its first error of any kind, so the UI stopped audio and played the rest of the playback without sound. cpal's ALSA backend reports transient errors (POLLERR after an xrun, spurious poll returns, short writes) that way and keeps the stream alive; after POLLERR it no longer calls back. Reports: a second or two of audio, then silence while meters move (#23). Fix: AudioOut::recover, called every playback frame, lets CpalOut restart the stream: at once when the device is gone, or when an error was reported and no samples were consumed for 300 ms. The restarted stream reuses the shared play-ahead mixer and played-frame clock and must keep the sample rate and channel count; at most 3 restarts in 10 s, then playback falls back to the wall clock as before. Errors while samples keep flowing no longer silence playback. The device callback runs the mixer under catch_unwind: a panic plays silence. Co-Authored-By: Claude Opus 5.5 --- apps/filmcraft/src/audio.rs | 347 ++++++++++++++++++++++---- crates/ui-egui/src/lib.rs | 55 ++++ crates/ui-egui/src/source_playback.rs | 5 + docs/gaps.md | 4 + 4 files changed, 359 insertions(+), 52 deletions(-) diff --git a/apps/filmcraft/src/audio.rs b/apps/filmcraft/src/audio.rs index f0e9bc489..cb56e059e 100644 --- a/apps/filmcraft/src/audio.rs +++ b/apps/filmcraft/src/audio.rs @@ -1,18 +1,33 @@ //! cpal audio output: the playback master clock. Settings ▸ Audio Hardware picks the host //! ("Device Class"), the output device, the I/O buffer size and the sample rate. +//! +//! A stream the device drops while playing (an ALSA xrun the backend does not recover from, which +//! cpal reports as `poll() returned POLLERR`; an unplugged device) is restarted by +//! [`AudioOut::recover`], keeping the played-frame clock and the play-ahead mixer, so sound +//! resumes where it stopped instead of playback going silent (#840). +use std::panic::{AssertUnwindSafe, catch_unwind}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::time::{Duration, Instant}; -use std::sync::{Arc, Mutex, PoisonError}; +use std::sync::{Arc, Mutex, PoisonError, TryLockError}; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use filmcraft_engine::settings::AudioHardwarePrefs; use filmcraft_ui_egui::{AudioDevices, AudioOut}; +/// The playback mixer callback handed to [`AudioOut::start`]. +type Fill = Box; + pub struct CpalOut { stream: Option, + /// The running playback's mixer, shared with the stream so a restarted stream continues it. + fill: Option>>, played: Arc, - failed: Arc, + health: Arc, + watchdog: Watchdog, + /// Time base for the watchdog. + epoch: Instant, rate: u32, channels: u16, hw: AudioHardwarePrefs, @@ -128,8 +143,11 @@ impl CpalOut { }; Self { stream: None, + fill: None, played: Arc::new(AtomicU64::new(0)), - failed: Arc::new(AtomicBool::new(false)), + health: Arc::new(Health::default()), + watchdog: Watchdog::default(), + epoch: Instant::now(), rate: cfg.as_ref().map_or(48_000, |c| c.sample_rate().0), channels: cfg.as_ref().map_or(2, |c| c.channels()), hw: AudioHardwarePrefs::default(), @@ -153,19 +171,14 @@ impl CpalOut { Ok(exact.unwrap_or(default)) } - fn converted_stream( - &self, - dev: &cpal::Device, - config: &cpal::StreamConfig, - mut fill: Box, - ) -> Result + fn converted_stream(&self, dev: &cpal::Device, config: &cpal::StreamConfig, fill: Arc>) -> Result where T: cpal::SizedSample + cpal::FromSample, { let channels = usize::from(config.channels); let played = self.played.clone(); - let failed = self.failed.clone(); - let error_flag = failed.clone(); + let health = self.health.clone(); + let error_health = health.clone(); let mut scratch = Vec::new(); dev.build_output_stream( config, @@ -173,39 +186,23 @@ impl CpalOut { // Reuse the conversion buffer. A pathological driver buffer must not panic the audio thread. if buf.len() > 1_048_576 || scratch.try_reserve(buf.len().saturating_sub(scratch.len())).is_err() { buf.fill(T::EQUILIBRIUM); - if !failed.swap(true, Ordering::SeqCst) { + if !health.failed.swap(true, Ordering::SeqCst) { eprintln!("filmcraft: unable to allocate the audio conversion buffer"); } return; } scratch.resize(buf.len(), 0.0); - fill(&mut scratch, channels); + render(&fill, &mut scratch, channels, &played, &health); convert_samples(&scratch, buf); - played.fetch_add((buf.len() / channels) as u64, Ordering::SeqCst); }, - move |e| stream_error(&error_flag, e), + move |e| stream_error(&error_health, e), None, ) } -} -fn stream_error(failed: &AtomicBool, e: cpal::StreamError) { - if !failed.swap(true, Ordering::SeqCst) { - eprintln!("filmcraft: audio stream error: {e}"); - } -} - -fn convert_samples>(input: &[f32], output: &mut [T]) { - // Integer conversion represents [-1, 1). In particular I24 cannot represent +1 exactly. - let upper = f32::from_bits(1.0_f32.to_bits() - 1); - for (sample, converted) in input.iter().zip(output) { - *converted = T::from_sample(if sample.is_finite() { sample.clamp(-1.0, upper) } else { 0.0 }); - } -} - -impl AudioOut for CpalOut { - fn start(&mut self, mut fill: Box) -> Result { - self.stop(); + /// Open the configured output device and play `fill` on it. `expect` is the (sample rate, + /// channels) a restarted stream must keep: the mixer was set up for them. + fn open(&mut self, fill: Arc>, expect: Option<(u32, u16)>) -> Result<(), String> { let host = host(&self.hw.device_class); let DeviceChoice { device: dev, fallback } = output_device(&host, &self.hw.default_output, &self.fallback).ok_or("no output device")?; self.note = fallback; @@ -214,6 +211,11 @@ impl AudioOut for CpalOut { if channels == 0 || cfg.sample_rate().0 == 0 { return Err("the output device returned an invalid channel count or sample rate".into()); } + if let Some((rate, ch)) = expect + && (rate, ch) != (cfg.sample_rate().0, cfg.channels()) + { + return Err(format!("the output device now runs at {} Hz with {} channels", cfg.sample_rate().0, cfg.channels())); + } let mut config: cpal::StreamConfig = cfg.clone().into(); if let cpal::SupportedBufferSize::Range { min, max } = cfg.buffer_size() && (*min..=*max).contains(&self.hw.buffer_size) @@ -222,20 +224,18 @@ impl AudioOut for CpalOut { } self.rate = cfg.sample_rate().0; self.channels = cfg.channels(); - self.played.store(0, Ordering::SeqCst); - self.failed.store(false, Ordering::SeqCst); - let played = self.played.clone(); - let failed = self.failed.clone(); let stream = match cfg.sample_format() { - cpal::SampleFormat::F32 => dev.build_output_stream( - &config, - move |buf: &mut [f32], _| { - fill(buf, channels); - played.fetch_add((buf.len() / channels) as u64, Ordering::SeqCst); - }, - move |e| stream_error(&failed, e), - None, - ), + cpal::SampleFormat::F32 => { + let played = self.played.clone(); + let health = self.health.clone(); + let error_health = health.clone(); + dev.build_output_stream( + &config, + move |buf: &mut [f32], _| render(&fill, buf, channels, &played, &health), + move |e| stream_error(&error_health, e), + None, + ) + } cpal::SampleFormat::I8 => self.converted_stream::(&dev, &config, fill), cpal::SampleFormat::I16 => self.converted_stream::(&dev, &config, fill), cpal::SampleFormat::I24 => self.converted_stream::(&dev, &config, fill), @@ -251,10 +251,172 @@ impl AudioOut for CpalOut { .map_err(|e| e.to_string())?; stream.play().map_err(|e| e.to_string())?; self.stream = Some(stream); + Ok(()) + } + + /// Replace a dropped stream with a new one on the same mixer, clock and format. + fn restart(&mut self) -> Result<(), String> { + let fill = self.fill.clone().ok_or("nothing is playing")?; + let expect = (self.rate, self.channels); + // The old stream (and its audio thread) goes first: only one stream ever runs the mixer. + self.stream = None; + self.health.clear_errors(); + self.open(fill, Some(expect)) + } +} + +/// What the audio thread reports to the UI thread. +#[derive(Default)] +struct Health { + /// The device went away (unplugged): restart the stream at once. + lost: AtomicBool, + /// A backend error since the last check (cpal keeps the stream alive after these): restart + /// only if the stream also stopped consuming samples. + errored: AtomicBool, + /// Unrecoverable: the mixer panicked, a buffer could not be allocated, or restarts ran out. + /// [`AudioOut::played_frames`] reports `None` and playback goes on without sound. + failed: AtomicBool, + /// The first error of a stream has been printed. + reported: AtomicBool, +} + +impl Health { + fn clear_errors(&self) { + self.lost.store(false, Ordering::SeqCst); + self.errored.store(false, Ordering::SeqCst); + self.reported.store(false, Ordering::SeqCst); + } +} + +/// How long a stream that reported an error may go without consuming samples before it is +/// restarted. Well under the UI's `AUDIO_STALL_S`, which gives up on the device clock. +const STALL_RESTART: Duration = Duration::from_millis(300); +/// At most this many restarts within [`RESTART_WINDOW`]; a device that keeps failing gives up. +const MAX_RESTARTS: usize = 3; +const RESTART_WINDOW: Duration = Duration::from_secs(10); + +/// What [`Watchdog::check`] decides about the running stream. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Verdict { + Healthy, + Restart, + GiveUp, +} + +/// Decides when a stream that reported errors is restarted (UI thread, every frame). +#[derive(Default)] +struct Watchdog { + /// Played-frame count last seen, and when it last changed. + seen: (u64, Duration), + /// An error was reported and the stream has not consumed samples since. + pending_error: bool, + /// When recent restarts happened (at most [`MAX_RESTARTS`] kept). + restarts: Vec, +} + +impl Watchdog { + fn reset(&mut self, now: Duration) { + *self = Self { seen: (0, now), ..Self::default() }; + } + + fn check(&mut self, now: Duration, played: u64, errored: bool, lost: bool) -> Verdict { + if played != self.seen.0 { + self.seen = (played, now); + self.pending_error = false; + } + // An error that arrives with the stream still consuming samples is only a hiccup. + self.pending_error |= errored; + let stalled = now.saturating_sub(self.seen.1) >= STALL_RESTART; + if !lost && !(self.pending_error && stalled) { + return Verdict::Healthy; + } + self.restarts.retain(|t| now.saturating_sub(*t) < RESTART_WINDOW); + if self.restarts.len() >= MAX_RESTARTS { + return Verdict::GiveUp; + } + self.restarts.push(now); + self.seen = (played, now); + self.pending_error = false; + Verdict::Restart + } +} + +/// One device callback: run the shared mixer into `buf` and advance the clock. Never panics: a +/// panicking mixer plays silence and fails the stream, and a mixer held by another stream (never +/// expected: a restart drops the old stream first) plays silence without advancing the clock. +fn render(fill: &Mutex, buf: &mut [f32], channels: usize, played: &AtomicU64, health: &Health) { + let mut guard = match fill.try_lock() { + Ok(guard) => guard, + Err(TryLockError::Poisoned(poisoned)) => poisoned.into_inner(), + Err(TryLockError::WouldBlock) => { + buf.fill(0.0); + return; + } + }; + if health.failed.load(Ordering::SeqCst) || catch_unwind(AssertUnwindSafe(|| (*guard)(buf, channels))).is_err() { + buf.fill(0.0); + if !health.failed.swap(true, Ordering::SeqCst) { + eprintln!("filmcraft: the audio mixer failed; playing without sound"); + } + return; + } + played.fetch_add((buf.len() / channels.max(1)) as u64, Ordering::SeqCst); +} + +fn stream_error(health: &Health, e: cpal::StreamError) { + match e { + cpal::StreamError::DeviceNotAvailable => health.lost.store(true, Ordering::SeqCst), + cpal::StreamError::BackendSpecific { .. } => health.errored.store(true, Ordering::SeqCst), + } + if !health.reported.swap(true, Ordering::SeqCst) { + eprintln!("filmcraft: audio stream error: {e}"); + } +} + +fn convert_samples>(input: &[f32], output: &mut [T]) { + // Integer conversion represents [-1, 1). In particular I24 cannot represent +1 exactly. + let upper = f32::from_bits(1.0_f32.to_bits() - 1); + for (sample, converted) in input.iter().zip(output) { + *converted = T::from_sample(if sample.is_finite() { sample.clamp(-1.0, upper) } else { 0.0 }); + } +} + +impl AudioOut for CpalOut { + fn start(&mut self, fill: Box) -> Result { + self.stop(); + self.played.store(0, Ordering::SeqCst); + self.health = Arc::new(Health::default()); + self.watchdog.reset(self.epoch.elapsed()); + let fill = Arc::new(Mutex::new(fill)); + self.open(fill.clone(), None)?; + self.fill = Some(fill); Ok(self.rate) } fn stop(&mut self) { self.stream = None; + self.fill = None; + } + fn recover(&mut self) { + if self.stream.is_none() || self.health.failed.load(Ordering::SeqCst) { + return; + } + let lost = self.health.lost.swap(false, Ordering::SeqCst); + let errored = self.health.errored.swap(false, Ordering::SeqCst); + match self.watchdog.check(self.epoch.elapsed(), self.played.load(Ordering::SeqCst), errored, lost) { + Verdict::Healthy => {} + Verdict::Restart => { + log::warn!("the audio output stream {}; restarting it", if lost { "lost its device" } else { "stopped after an error" }); + if let Err(e) = self.restart() { + log::warn!("the audio output stream could not be restarted: {e}"); + self.stream = None; + self.health.failed.store(true, Ordering::SeqCst); + } + } + Verdict::GiveUp => { + log::warn!("the audio output stream keeps failing ({MAX_RESTARTS} restarts within {} s)", RESTART_WINDOW.as_secs()); + self.health.failed.store(true, Ordering::SeqCst); + } + } } fn sample_rate(&self) -> u32 { self.rate @@ -263,7 +425,7 @@ impl AudioOut for CpalOut { self.channels as usize } fn played_frames(&self) -> Option { - self.stream.as_ref().filter(|_| !self.failed.load(Ordering::SeqCst)).map(|_| self.played.load(Ordering::SeqCst)) + self.stream.as_ref().filter(|_| !self.health.failed.load(Ordering::SeqCst)).map(|_| self.played.load(Ordering::SeqCst)) } fn devices(&self) -> AudioDevices { let h = host(&self.hw.device_class); @@ -327,11 +489,92 @@ mod tests { } #[test] - fn failed_stream_relinquishes_the_playback_clock() { - let failed = AtomicBool::new(false); - stream_error(&failed, cpal::StreamError::DeviceNotAvailable); - assert!(failed.load(Ordering::SeqCst)); + fn stream_errors_ask_for_a_restart_instead_of_failing_playback() { + let health = Health::default(); + stream_error(&health, cpal::StreamError::BackendSpecific { err: cpal::BackendSpecificError { description: "`alsa::poll()` returned POLLERR".into() } }); + assert!(health.errored.load(Ordering::SeqCst) && !health.lost.load(Ordering::SeqCst)); + stream_error(&health, cpal::StreamError::DeviceNotAvailable); + assert!(health.lost.load(Ordering::SeqCst)); + assert!(!health.failed.load(Ordering::SeqCst), "a stream error alone must not silence playback"); let output = CpalOut::new(); - assert_eq!(output.played_frames(), None); + assert_eq!(output.played_frames(), None, "no stream, no clock"); + } + + fn ms(n: u64) -> Duration { + Duration::from_millis(n) + } + + #[test] + fn an_error_while_samples_keep_flowing_is_only_a_hiccup() { + let mut w = Watchdog::default(); + w.reset(ms(0)); + assert_eq!(w.check(ms(10), 480, true, false), Verdict::Healthy); + // the stream keeps consuming samples after the error: never restarted + for (i, t) in (20..2_000).step_by(20).enumerate() { + assert_eq!(w.check(ms(t), 480 * (i as u64 + 2), false, false), Verdict::Healthy); + } + } + + #[test] + fn a_stream_that_errors_and_stops_consuming_is_restarted() { + let mut w = Watchdog::default(); + w.reset(ms(0)); + assert_eq!(w.check(ms(10), 480, false, false), Verdict::Healthy); + // POLLERR: the backend reports errors and no longer calls back + assert_eq!(w.check(ms(20), 480, true, false), Verdict::Healthy); + assert_eq!(w.check(ms(200), 480, true, false), Verdict::Healthy); + assert_eq!(w.check(ms(310), 480, false, false), Verdict::Restart); + // the restarted stream gets a fresh grace period + assert_eq!(w.check(ms(400), 480, false, false), Verdict::Healthy); + // a stall without any reported error is left to the UI's own watchdog + assert_eq!(w.check(ms(2_000), 480, false, false), Verdict::Healthy); + } + + #[test] + fn a_lost_device_restarts_at_once_and_repeated_failures_give_up() { + let mut w = Watchdog::default(); + w.reset(ms(0)); + assert_eq!(w.check(ms(5), 0, false, true), Verdict::Restart); + assert_eq!(w.check(ms(10), 0, false, true), Verdict::Restart); + assert_eq!(w.check(ms(15), 0, false, true), Verdict::Restart); + assert_eq!(w.check(ms(20), 0, false, true), Verdict::GiveUp); + // sporadic failures far apart keep being recovered + assert_eq!(w.check(ms(10_006), 0, false, true), Verdict::Restart); + } + + #[test] + fn the_device_callback_never_panics_and_counts_played_frames() { + let (played, health) = (AtomicU64::new(0), Health::default()); + let fill: Mutex = Mutex::new(Box::new(|buf: &mut [f32], _| buf.fill(0.25))); + let mut buf = [1.0_f32; 8]; + render(&fill, &mut buf, 2, &played, &health); + assert_eq!((buf, played.load(Ordering::SeqCst)), ([0.25; 8], 4)); + // a mixer still held elsewhere plays silence without moving the clock + { + let _held = fill.lock().unwrap(); + render(&fill, &mut buf, 2, &played, &health); + } + assert_eq!((buf, played.load(Ordering::SeqCst)), ([0.0; 8], 4)); + // zero channels from a broken driver must not divide by zero + render(&fill, &mut buf, 0, &played, &health); + assert_eq!(played.load(Ordering::SeqCst), 12); + } + + #[test] + fn a_panicking_mixer_plays_silence_and_fails_the_stream() { + let (played, health) = (AtomicU64::new(0), Health::default()); + let fill: Mutex = Mutex::new(Box::new(|buf: &mut [f32], _| { + buf.fill(0.5); + panic!("mixer bug"); + })); + let mut buf = [1.0_f32; 4]; + render(&fill, &mut buf, 2, &played, &health); + assert_eq!(buf, [0.0; 4]); + assert_eq!(played.load(Ordering::SeqCst), 0); + assert!(health.failed.load(Ordering::SeqCst)); + // later callbacks stay silent instead of re-entering the broken mixer + buf = [1.0; 4]; + render(&fill, &mut buf, 2, &played, &health); + assert_eq!(buf, [0.0; 4]); } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9c4c526f7..361cc9489 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -80,6 +80,10 @@ pub trait AudioOut { } /// Frames played since `start` (the playback master clock), if the device reports it. fn played_frames(&self) -> Option; + /// Called every frame while playing, before `played_frames`: restart a stream the device + /// dropped (a driver error, an unplugged device), keeping the clock and `fill`. A stream that + /// cannot be restarted reports `played_frames() == None`. + fn recover(&mut self) {} /// Hosts and devices that can be chosen in Settings ▸ Audio Hardware. fn devices(&self) -> AudioDevices { AudioDevices::default() @@ -1013,6 +1017,11 @@ impl FilmcraftApp { self.playback.anchor_time = now; } let rate = self.session.sequence_rate(); + if self.playback.audio_clock + && let Some(audio) = self.audio.as_mut() + { + audio.recover(); + } let reading = if self.playback.audio_clock { self.audio.as_ref().and_then(|a| a.played_frames().map(|f| (f, a.sample_rate()))) } else { None }; if self.playback.audio_clock && reading.is_none() { // A device error relinquishes its clock immediately. Resume from the displayed @@ -2263,6 +2272,52 @@ mod audio_recovery_tests { assert!(app.ui.status.contains("Audio output failed")); app.stop(); } + + /// A stream the device dropped, which `recover` restarts on the same clock. + struct Restartable { + reading: Option, + } + + impl AudioOut for Restartable { + fn start(&mut self, _: Box) -> Result { + Ok(48000) + } + fn stop(&mut self) {} + fn sample_rate(&self) -> u32 { + 48000 + } + fn played_frames(&self) -> Option { + self.reading + } + fn recover(&mut self) { + self.reading = self.reading.or(Some(48_000)); + } + } + + #[test] + fn a_restarted_stream_keeps_the_device_clock_and_the_sound() { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).unwrap(); + let ctx = egui::Context::default(); + let mut app = FilmcraftApp::new(session); + app.audio = Some(Box::new(Restartable { reading: None })); + app.playback.playing = true; + app.playback.audio_clock = true; + app.playback.anchor_tick = Tick::ZERO; + app.playback.anchor_time = 0.0; + app.playback.audio_seen = (0, 10.0); + let mut output = ctx.run_ui(egui::RawInput { time: Some(10.0), ..Default::default() }, |ui| app.advance_playback(ui.ctx())); + output.textures_delta.clear(); + assert!(app.playback.audio_clock, "the restarted stream is still the master clock"); + assert!(!app.ui.status.contains("Audio output failed")); + // 48 000 frames played at 48 kHz: one second in, on a frame boundary + let (shown, second) = (app.session.playhead(), Tick::from_seconds_f64(1.0)); + assert!( + shown <= second && shown + app.session.sequence_rate().frame_duration() > second, + "the playhead follows the frames the device played: {shown:?}" + ); + app.stop(); + } } /// A physical-pixel screen position as a point in the window's UI space, given the window's content origin in points. diff --git a/crates/ui-egui/src/source_playback.rs b/crates/ui-egui/src/source_playback.rs index 7cdb7c21f..2a783325d 100644 --- a/crates/ui-egui/src/source_playback.rs +++ b/crates/ui-egui/src/source_playback.rs @@ -213,6 +213,11 @@ impl FilmcraftApp { self.source_playback.clock.audio_seen = (0, now); self.start_source_audio(); } + if self.source_playback.clock.audio_clock + && let Some(audio) = self.audio.as_mut() + { + audio.recover(); + } let clock = &mut self.source_playback.clock; if clock.anchor_time < 0.0 { clock.anchor_time = now; diff --git a/docs/gaps.md b/docs/gaps.md index f798de682..e54135eae 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -229,6 +229,10 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** - **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. +- **Have:** a stream the device drops while playing (an ALSA xrun cpal reports as `POLLERR`, an + unplugged device) is restarted with the same rate, channels, clock and mixer position, so sound + resumes instead of playback going silent (at most 3 restarts in 10 s); a panicking mixer plays + silence (#840). - **Estimate:** 20–40 h. ## G18. Effects depth: approximations and missing behaviour From 09f627a73a04edb41ef3eec9390cc07dad89434d Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 01:51:47 +0200 Subject: [PATCH 157/260] #844: on-monitor corner-roundness handle for masks Problem: masks.roundCorners (#813) rounded rectangle/polygon mask corners only from the command line, control channel or MCP; the Program monitor had no Premiere 26-style corner-roundness handle. Fix: a selected mask with sharp corners between straight edges shows a roundness handle just inside its top-left corner (automation id program.mask.roundness). Dragging it into the shape moves it along the corner bisector, previews the rounded outline live with the radius, and on release dispatches one masks.roundCorners (one undo step per drag). The radius is capped where no corner can grow further. Ellipses, pen masks and rounded corners show no handle. Tooltip translated (es/fr/ja/zh-cn). New headless UI test mask_roundness_ui; docs/gaps.md G18 updated. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/zh-cn.tsv | 1 + crates/ui-egui/src/panels/masks.rs | 123 ++++++++++++++++- crates/ui-egui/tests/mask_roundness_ui.rs | 158 ++++++++++++++++++++++ docs/gaps.md | 6 +- 7 files changed, 286 insertions(+), 5 deletions(-) create mode 100644 crates/ui-egui/tests/mask_roundness_ui.rs diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 76cc3066b..692ac2b9a 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3488,6 +3488,7 @@ Click to select, or click in empty space and drag to marquee select. Use Shift, Alt, and Ctrl 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, Alt y Ctrl para otras opciones. Click to zoom in; Alt-click to zoom out. Haz clic para acercar; Alt+clic para alejar. Drag: move · Alt-click: smooth/corner · Ctrl-click: delete Arrastrar: mover · Alt+clic: suave/esquina · Ctrl+clic: eliminar + Drag inward to round the corners Arrastrar hacia dentro para redondear las esquinas Include Alpha Channel Incluir canal alfa Sequence Settings Ajustes de secuencia Color Management Gestión del color diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 5f17a4f44..170b8011a 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3478,6 +3478,7 @@ 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 + Drag inward to round the corners Glisser vers l'intérieur pour arrondir les angles Include Alpha Channel Inclure la couche alpha Sequence Settings Paramètres de séquence Color Management Gestion des couleurs diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 2c160f4b6..41d31f6c8 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3487,6 +3487,7 @@ Click to select, or click in empty space and drag to marquee select. Use Shift, Alt, and Ctrl for other options. クリックで選択します。何もない場所からドラッグすると範囲選択になります。Shift、Alt、Ctrl キーを併用すると、ほかのオプションも使えます。 Click to zoom in; Alt-click to zoom out. クリックでズームイン、Alt+クリックでズームアウトします。 Drag: move · Alt-click: smooth/corner · Ctrl-click: delete ドラッグ: 移動 · Alt+クリック: スムーズ/コーナー · Ctrl+クリック: 削除 + Drag inward to round the corners 内側にドラッグして角を丸める Include Alpha Channel アルファチャンネルを含める Sequence Settings シーケンス設定 Color Management カラーマネジメント diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index 9e0e78038..d74a619bf 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -3471,6 +3471,7 @@ Click to select, or click in empty space and drag to marquee select. Use Shift, Alt, and Ctrl for other options. 单击以选择,或在空白处单击并拖动以框选。使用 Shift、Alt 和 Ctrl 进行其他操作。 Click to zoom in; Alt-click to zoom out. 单击以放大;Alt-单击以缩小。 Drag: move · Alt-click: smooth/corner · Ctrl-click: delete 拖动:移动 · Alt-单击:平滑/拐角 · Ctrl-单击:删除 + Drag inward to round the corners 向内拖动以使拐角变圆 Include Alpha Channel 包含 Alpha 通道 Sequence Settings 序列设置 Color Management 色彩管理 diff --git a/crates/ui-egui/src/panels/masks.rs b/crates/ui-egui/src/panels/masks.rs index 5062918ea..eb479c17e 100644 --- a/crates/ui-egui/src/panels/masks.rs +++ b/crates/ui-egui/src/panels/masks.rs @@ -1,13 +1,13 @@ //! Effect masks in the UI: the mask rows of Effect Controls (create ellipse / 4-point polygon / //! pen masks, per-mask Path / Feather / Opacity / Expansion / Inverted / mode) and on-monitor -//! editing on the Program monitor (drag vertices, Bézier handles, the whole mask, feather and -//! expansion handles; the pen places a free-draw Bézier mask). +//! editing on the Program monitor (drag vertices, Bézier handles, the whole mask, feather, +//! expansion and corner-roundness handles; the pen places a free-draw Bézier mask). //! //! Automation ids: `effectControls..mask.{ellipse,polygon,pen}`, //! `effectControls..mask` (+ `.mode`, `.inverted`, `.delete`, and the parameter rows //! `effectControls..mask..*`), `program.mask.vertex.`, `program.mask.in.`, //! `program.mask.out.`, `program.mask.body`, `program.mask.feather`, `program.mask.expansion`, -//! `program.maskPen`. +//! `program.mask.roundness`, `program.maskPen`. use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, pos2, vec2}; use filmcraft_geom::{Affine, Vec2}; @@ -344,6 +344,8 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra } } } + // ---- corner roundness (masks with sharp corners: rectangles, polygons) + actions.extend(roundness_handle(app, ui, &painter, &path, &to_screen, &target)); // ---- tangent handles and vertices let sel_v_id = egui::Id::new(("mask-sel-vertex", sel.clip.0, sel.effect, sel.mask)); let sel_v: Option = ui.data(|d| d.get_temp(sel_v_id)); @@ -436,6 +438,121 @@ fn seg_dist(p: Pos2, a: Pos2, b: Pos2) -> f32 { (p - (a + ab * t)).length() } +/// A sharp corner `masks.roundCorners` can fillet: its point, the unit bisector pointing to the +/// fillet's centre, `sin(θ/2)` of its opening angle θ, and the largest radius it takes (the fillet +/// never uses more than half of either edge). +struct RoundableCorner { + p: Vec2, + bisector: Vec2, + sin_half: f64, + max_radius: f64, +} + +/// The corners of `path` that rounding changes (degenerate ones — straight, reversing, zero-length +/// edges — are left out). +fn roundable_corners(path: &MaskPath) -> Vec { + let n = path.vertices.len(); + path.roundable_corners() + .into_iter() + .filter_map(|i| { + let v = path.vertices.get(i)?.p; + let prev = path.vertices.get((i + n - 1) % n)?.p; + let next = path.vertices.get((i + 1) % n)?.p; + let (a, b) = (prev - v, next - v); + let (la, lb) = (a.length(), b.length()); + if !(la > 1e-6 && lb > 1e-6) { + return None; + } + let (u, w) = (a * (1.0 / la), b * (1.0 / lb)); + let theta = (u.x * w.x + u.y * w.y).clamp(-1.0, 1.0).acos(); + if !(1e-6..std::f64::consts::PI - 1e-6).contains(&theta) { + return None; + } + let s = u + w; + let sl = s.length(); + if !sl.is_finite() || sl <= 1e-9 { + return None; + } + let half = theta / 2.0; + let max_radius = la.min(lb) / 2.0 * half.tan(); + max_radius.is_finite().then_some(RoundableCorner { p: v, bisector: s * (1.0 / sl), sin_half: half.sin(), max_radius }) + }) + .collect() +} + +/// Radius of a roundness drag that has moved `dist` clip pixels along the corner's bisector: the +/// handle follows the fillet's centre, so it sits `r / sin(θ/2)` from the corner. +fn drag_radius(dist: f64, c: &RoundableCorner, max_radius: f64) -> f64 { + let r = dist * c.sin_half; + if r.is_finite() { r.clamp(0.0, max_radius.max(0.0)) } else { 0.0 } +} + +/// The corner-roundness handle of the selected mask, just inside its top-left sharp corner. +/// Dragging it into the shape previews the rounded corners live; releasing rounds every sharp +/// corner with one `masks.roundCorners` (one undo step per drag). Masks without a sharp corner +/// between straight edges (ellipses, pen masks, corners already rounded) show no handle. +fn roundness_handle( + app: &mut FilmcraftApp, + ui: &mut egui::Ui, + painter: &egui::Painter, + path: &MaskPath, + to_screen: &Affine, + target: &Value, +) -> Option<(String, Value)> { + let corners = roundable_corners(path); + let to_clip = to_screen.inverse()?; + let lin_inv = Affine { e: 0.0, f: 0.0, ..to_clip }; + let c = corners.iter().min_by(|a, b| { + let (pa, pb) = (sp(to_screen, a.p), sp(to_screen, b.p)); + (pa.x + pa.y).total_cmp(&(pb.x + pb.y)) + })?; + let max_radius = corners.iter().map(|c| c.max_radius).fold(0.0, f64::max); + let id = egui::Id::new("mask-roundness"); + let dist_id = id.with("dist"); + let dragging = ui.ctx().is_being_dragged(id); + let dist: f64 = if dragging { ui.data(|d| d.get_temp(dist_id)).unwrap_or(0.0) } else { 0.0 }; + let radius = drag_radius(dist, c, max_radius); + let anchor = sp(to_screen, c.p); + let dir = sp(to_screen, c.p + c.bisector) - anchor; + let dir = if dir.length() > 1e-6 { dir.normalized() } else { egui::Vec2::new(1.0, 1.0).normalized() }; + let hp = sp(to_screen, c.p + c.bisector * (radius / c.sin_half.max(1e-9))) + dir * 18.0; + let hr = Rect::from_center_size(hp, vec2(9.0, 9.0)); + let r = ui.interact(hr.expand(3.0), id, Sense::drag()).on_hover_text(tl!("Drag inward to round the corners")); + app.auto.add("program.mask.roundness", hr, "Mask corner roundness"); + let col = Color32::from_rgb(0x5a, 0xb4, 0xff); + if dragging && radius > 0.0 { + let preview = path.with_rounded_corners(radius, &path.roundable_corners()).flatten(0.5); + painter.add(egui::Shape::closed_line(preview.iter().map(|v| sp(to_screen, *v)).collect(), Stroke::new(1.5, Color32::WHITE))); + painter.text(hp + vec2(10.0, -10.0), Align2::LEFT_BOTTOM, format!("{radius:.1}"), Tokens::ui(10.5), app.tokens.hot_text); + } + painter.line_segment([anchor, hp], Stroke::new(1.0, col.gamma_multiply(0.6))); + painter.circle_filled(hp, 4.0, Color32::WHITE); + painter.circle_stroke(hp, 4.0, Stroke::new(1.5, col)); + let primary = egui::PointerButton::Primary; + if r.drag_started_by(primary) { + ui.data_mut(|d| d.insert_temp(dist_id, 0.0_f64)); + } + if r.dragged_by(primary) && r.drag_delta() != egui::Vec2::ZERO { + let d = lin_inv.apply(Vec2::new(r.drag_delta().x as f64, r.drag_delta().y as f64)); + let along = d.x * c.bisector.x + d.y * c.bisector.y; + // accumulate the raw distance: past the limit the handle waits for the pointer to come back + let total = ui.data(|m| m.get_temp::(dist_id)).unwrap_or(0.0) + along; + ui.data_mut(|m| m.insert_temp(dist_id, if total.is_finite() { total } else { 0.0 })); + } + if r.drag_stopped_by(primary) { + let dist: f64 = ui.data_mut(|d| d.remove_temp(dist_id)).unwrap_or(0.0); + let radius = (drag_radius(dist, c, max_radius) * 10.0).round() / 10.0; + if radius > 0.0 { + let mut p = target.clone(); + if let Some(o) = p.as_object_mut() { + o.insert("radius".into(), json!(radius)); + } + return Some(("masks.roundCorners".into(), p)); + } + } + None +} + /// Draft vertices → a mask path. pub fn draft_path(points: &[[f64; 4]]) -> MaskPath { MaskPath { diff --git a/crates/ui-egui/tests/mask_roundness_ui.rs b/crates/ui-egui/tests/mask_roundness_ui.rs new file mode 100644 index 000000000..4311afcf7 --- /dev/null +++ b/crates/ui-egui/tests/mask_roundness_ui.rs @@ -0,0 +1,158 @@ +//! The corner-roundness handle of rectangle / polygon masks on the Program monitor, driven +//! headless through the control channel: dragging it rounds the corners in one undo step; masks +//! without sharp corners show no handle. +//! +//! With `FILMCRAFT_UI_SHOTS=` the test also renders the UI with wgpu and writes PNGs there. + +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, + shots: Option, +} + +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 shots = std::env::var_os("FILMCRAFT_UI_SHOTS").map(std::path::PathBuf::from); + let mut b = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000); + if shots.is_some() { + b = b.wgpu().with_pixels_per_point(1.0); + } + let harness = b.build_eframe(move |_cc| app); + let mut d = Driver { harness, tx, shots }; + 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 call(&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() { + return v; + } + } + panic!("no reply to {method} {params}"); + } + fn ok(&mut self, method: &str, params: Value) -> Value { + let r = self.call(method, params.clone()); + assert_eq!(r["ok"], true, "{method} {params}: {r}"); + r["result"].clone() + } + 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}: {v}")).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 center(&mut self, id: &str) -> (f64, f64) { + let r = self.rect(id); + (r[0] + r[2] / 2.0, r[1] + r[3] / 2.0) + } + fn drag(&mut self, id: &str, dx: f64, dy: f64) { + let (x, y) = self.center(id); + self.ok("ui.drag", json!({"from": {"x": x, "y": y}, "to": {"x": x + dx, "y": y + dy}, "steps": 6})); + self.frames(2); + } + fn has(&mut self, id: &str) -> bool { + let v = self.ok("ui.elements", json!({"prefix": id})); + v.as_array().is_some_and(|a| a.iter().any(|e| e["id"] == id)) + } + fn masks(&mut self) -> Vec { + self.exec("masks.list", json!({}))["masks"].as_array().unwrap().clone() + } + fn shot(&mut self, name: &str) { + let Some(dir) = self.shots.clone() else { return }; + // let the background frame workers finish the (blurred) frame + for _ in 0..40 { + self.frames(1); + std::thread::sleep(std::time::Duration::from_millis(25)); + } + let img = self.harness.render().expect("wgpu render"); + std::fs::create_dir_all(&dir).unwrap(); + img.save(dir.join(format!("{name}.png"))).unwrap(); + } +} + +fn vertex_count(m: &Value) -> usize { + m["path"]["vertices"].as_array().map_or(0, Vec::len) +} + +#[test] +fn roundness_handle_rounds_mask_corners_in_one_undo_step() { + let mut d = Driver::demo(); + d.ok("ui.set", json!({"workspace": "Effects"})); + let seq = d.exec("sequence.inspect", json!({})); + let clip = &seq["video"][0]["items"][0]; + let (id, start, dur) = (clip["clip"].as_u64().unwrap(), clip["start"].as_i64().unwrap(), clip["duration"].as_i64().unwrap()); + d.exec("playhead.set", json!({"time": start + dur / 2})); + d.exec("timeline.select", json!({"clips": [id]})); + + // an ellipse has no sharp corner: no roundness handle + d.exec("masks.add", json!({"clip": id, "effect": "opacity", "shape": "ellipse"})); + d.exec("masks.select", json!({"clip": id, "effect": "opacity", "mask": 0})); + d.frames(3); + assert!(d.has("program.mask.vertex.0"), "the mask overlay is on the monitor"); + assert!(!d.has("program.mask.roundness"), "no roundness handle on an ellipse"); + + // a rectangle has one, just inside its top-left corner + d.exec("masks.add", json!({"clip": id, "effect": "opacity", "shape": "rectangle", "center": [960, 540], "size": [960, 540]})); + d.exec("masks.select", json!({"clip": id, "effect": "opacity", "mask": 1})); + d.frames(3); + assert!(d.has("program.mask.roundness")); + let corner = d.center("program.mask.vertex.0"); + let handle = d.center("program.mask.roundness"); + assert!(handle.0 > corner.0 && handle.1 > corner.1, "handle {handle:?} inside the top-left corner {corner:?}"); + assert_eq!(vertex_count(&d.masks()[1]), 4); + d.shot("mask-roundness-handle"); + + // a click without a drag changes nothing + let undo0 = d.exec("history.list", json!({}))["undo"].as_array().unwrap().len(); + d.ok("ui.click", json!({"id": "program.mask.roundness"})); + d.frames(2); + assert_eq!(vertex_count(&d.masks()[1]), 4); + assert_eq!(d.exec("history.list", json!({}))["undo"].as_array().unwrap().len(), undo0); + + // dragging inward rounds all four corners: eight vertices, one undo step + d.drag("program.mask.roundness", 30.0, 30.0); + let m = d.masks(); + assert_eq!(vertex_count(&m[1]), 8, "{}", m[1]["path"]); + let undo1 = d.exec("history.list", json!({}))["undo"].as_array().unwrap().len(); + assert_eq!(undo1, undo0 + 1, "one undo step per drag"); + // the first fillet starts below the old top-left corner (960 − 480, 540 − 270) + let p = &m[1]["path"]["vertices"][0]["p"]; + let (x, y) = (p[0].as_f64().unwrap(), p[1].as_f64().unwrap()); + assert!((x - 480.0).abs() < 1e-6 && y > 270.0 + 10.0, "fillet start ({x}, {y})"); + d.shot("mask-rounded-corners"); + // the rounded corners are no longer sharp: the handle is gone + d.frames(2); + assert!(!d.has("program.mask.roundness")); + + d.exec("edit.undo", json!({})); + d.frames(2); + assert_eq!(vertex_count(&d.masks()[1]), 4); + assert!(d.has("program.mask.roundness")); +} diff --git a/docs/gaps.md b/docs/gaps.md index 564541067..52ef93ed7 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -235,13 +235,15 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** - **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 (constrained lines; an on-monitor corner-roundness handle). + redesigned 26.0 mask tools (constrained lines; a stored, re-editable corner radius: rounding + bakes fillets into the path, so a rounded corner cannot be re-rounded later). - **Have:** Optical Flow time interpolation synthesises motion-compensated in-between frames (dense 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); rectangle and polygon masks round their corners with circular fillets (`masks.add` `cornerRadius`, `masks.roundCorners`, every path keyframe; - #813). + #813), from an on-monitor corner-roundness handle with a live preview (one undo step per drag; + #844). - **Estimate:** 60–110 h. ## G19. Speech to text off in release builds From 1edcaa4e78f873eadd74df80018a40d271803057 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 02:09:49 +0200 Subject: [PATCH 158/260] =?UTF-8?q?#853:=20Shift=20constrains=20pen=20mask?= =?UTF-8?q?=20segments=20to=2045=C2=B0=20steps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: the pen mask tool ignored Shift, so straight horizontal, vertical or diagonal mask edges could not be drawn precisely (Premiere 26.0 constrains mask lines with Shift). Separately, path_from_json accepted the `{"p","in","out"}` vertex form through serde first, which silently dropped the `in`/`out` tangents, so a pen mask's dragged-out curves were committed as corners. Fix: with Shift held, a new pen point snaps onto the nearest 45° ray from the previous point (in screen space), the rubber-band preview follows, and a Shift-drag constrains the Bézier handle it pulls out. path_from_json now parses the vertex forms before falling back to the serialized MaskPath. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/masks.rs | 9 ++++-- crates/engine/src/masks_tests.rs | 19 ++++++++++++ crates/ui-egui/src/panels/masks.rs | 46 ++++++++++++++++++++++++++++-- crates/ui-egui/tests/masks_ui.rs | 44 ++++++++++++++++++++++++++++ docs/gaps.md | 5 ++-- 5 files changed, 115 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/masks.rs b/crates/engine/src/masks.rs index ffa5a5a16..37da8ac09 100644 --- a/crates/engine/src/masks.rs +++ b/crates/engine/src/masks.rs @@ -91,9 +91,12 @@ pub(crate) fn commands() -> Vec { /// A mask path from JSON: the serialized [`MaskPath`] object, an array of vertices /// (`{"p":[x,y],"in":[x,y]?,"out":[x,y]?}`), or an array of `[x, y]` / `{"x","y"}` points. pub fn path_from_json(v: &Value) -> Option { - if let Ok(p) = serde_json::from_value::(v.clone()) { - return Some(p); - } + // The vertex forms first: serde also accepts `{"p","in","out"}` vertices (the `masks.list` / + // `masks.add` form) but silently drops the unknown `in` / `out` tangents. + vertex_path(v).or_else(|| serde_json::from_value::(v.clone()).ok()) +} + +fn vertex_path(v: &Value) -> Option { let arr = v.as_array().or_else(|| v.get("vertices").and_then(Value::as_array))?; let closed = v.get("closed").and_then(Value::as_bool).unwrap_or(true); let mut vertices = Vec::new(); diff --git a/crates/engine/src/masks_tests.rs b/crates/engine/src/masks_tests.rs index 1fc069a84..c25cb4680 100644 --- a/crates/engine/src/masks_tests.rs +++ b/crates/engine/src/masks_tests.rs @@ -380,3 +380,22 @@ fn rounding_an_animated_mask_rounds_every_keyframe_and_only_the_chosen_corners() assert_eq!(v.len(), 6); assert!((v[0]["p"][0].as_f64().unwrap() - 450.0).abs() < 1e-6, "{}", v[0]); } + +/// `path_to_json` → `path_from_json` keeps the Bézier tangents (they used to come back as zero, so +/// a pen mask's dragged-out curves were committed as corners; #853). +#[test] +fn path_json_round_trip_keeps_tangents() { + use filmcraft_geom::Vec2; + use filmcraft_project::mask::{MaskPath, MaskVertex}; + let path = MaskPath { + vertices: vec![ + MaskVertex { p: Vec2::new(10.0, 20.0), t_in: Vec2::new(-5.0, 0.0), t_out: Vec2::new(5.0, 0.0) }, + MaskVertex::corner(Vec2::new(80.0, 20.0)), + MaskVertex { p: Vec2::new(40.0, 90.0), t_in: Vec2::new(3.0, -4.0), t_out: Vec2::new(-3.0, 4.0) }, + ], + closed: true, + }; + assert_eq!(crate::masks::path_from_json(&crate::masks::path_to_json(&path)), Some(path.clone())); + // the serialized project form still parses + assert_eq!(crate::masks::path_from_json(&serde_json::to_value(&path).unwrap()), Some(path)); +} diff --git a/crates/ui-egui/src/panels/masks.rs b/crates/ui-egui/src/panels/masks.rs index 5062918ea..c3cd161ba 100644 --- a/crates/ui-egui/src/panels/masks.rs +++ b/crates/ui-egui/src/panels/masks.rs @@ -464,6 +464,12 @@ fn pen_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, frame: (u32 let painter = ui.painter().with_clip_rect(pic); let to_c = |p: Pos2| to_clip.apply(Vec2::new(p.x as f64, p.y as f64)); let mut d = draft.clone(); + // Shift constrains the segment from the previous point (and a dragged-out handle) to 45° steps + let shift = ui.input(|i| i.modifiers.shift); + let snap = |from: Option<&[f64; 4]>, p: Pos2| match from { + Some(q) if shift => constrain_45(sp(&to_screen, Vec2::new(q[0], q[1])), p), + _ => p, + }; let mut commit = false; let pressed = resp.drag_started() || (resp.clicked() && !ui.data(|m| m.get_temp::(egui::Id::new("mask-pen-placed")).unwrap_or(false))); if pressed && let Some(p) = resp.interact_pointer_pos() { @@ -471,7 +477,7 @@ fn pen_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, frame: (u32 if d.points.len() >= 3 && near_first { commit = true; } else { - let c = to_c(p); + let c = to_c(snap(d.points.last(), p)); d.points.push([c.x, c.y, 0.0, 0.0]); ui.data_mut(|m| m.insert_temp(egui::Id::new("mask-pen-placed"), resp.drag_started())); } @@ -483,7 +489,7 @@ fn pen_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, frame: (u32 && !commit && let (Some(p), Some(last)) = (resp.interact_pointer_pos(), d.points.last_mut()) { - let c = to_c(p); + let c = to_c(snap(Some(last), p)); let tan = Vec2::new(c.x - last[0], c.y - last[1]); if tan.length() > 2.0 { last[2] = tan.x; @@ -504,7 +510,7 @@ fn pen_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, frame: (u32 painter.add(egui::Shape::line(pts, Stroke::new(1.5, col))); } if let (Some(h), Some(l)) = (resp.hover_pos(), d.points.last()) { - painter.line_segment([sp(&to_screen, Vec2::new(l[0], l[1])), h], Stroke::new(1.0, col.gamma_multiply(0.5))); + painter.line_segment([sp(&to_screen, Vec2::new(l[0], l[1])), snap(Some(l), h)], Stroke::new(1.0, col.gamma_multiply(0.5))); } for (i, q) in d.points.iter().enumerate() { let p = sp(&to_screen, Vec2::new(q[0], q[1])); @@ -549,6 +555,19 @@ fn open_polyline(path: &MaskPath, to_screen: &Affine) -> Vec { out } +/// `to` moved onto the nearest 45° ray from `from` (horizontal, vertical or diagonal on screen), +/// keeping its distance along that ray; `to` itself when it coincides with `from` or is not finite. +fn constrain_45(from: Pos2, to: Pos2) -> Pos2 { + let v = to - from; + if !(v.x.is_finite() && v.y.is_finite()) || v.length_sq() == 0.0 { + return to; + } + let step = std::f32::consts::FRAC_PI_4; + let a = (v.y.atan2(v.x) / step).round() * step; + let dir = egui::Vec2::new(a.cos(), a.sin()); + from + dir * v.dot(dir) +} + /// The Mask Path row's value area: mask tracking (backward continuously / one frame, forward one /// frame / continuously; clicking while a track runs stops it) and the tracking-method menu. pub fn path_value(app: &mut FilmcraftApp, ui: &mut egui::Ui, clip: ClipId, effect: usize, mask: Option, actions: &mut Vec<(String, Value)>) { @@ -607,3 +626,24 @@ pub fn path_value(app: &mut FilmcraftApp, ui: &mut egui::Ui, clip: ClipId, effec ui.ctx().request_repaint(); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn constrain_45_snaps_to_the_nearest_eighth_turn() { + let o = pos2(10.0, 20.0); + let near = |a: Pos2, b: Pos2| (a - b).length() < 1e-3; + // nearly horizontal / vertical: the off-axis component goes + assert!(near(constrain_45(o, pos2(110.0, 30.0)), pos2(110.0, 20.0))); + assert!(near(constrain_45(o, pos2(5.0, -80.0)), pos2(10.0, -80.0))); + // nearly diagonal: projected onto the 45° ray + let d = constrain_45(o, pos2(60.0, 66.0)); + assert!(((d.x - o.x) - (d.y - o.y)).abs() < 1e-3, "{d:?}"); + // degenerate input comes back unchanged + assert_eq!(constrain_45(o, o), o); + let nan = pos2(f32::NAN, 1.0); + assert!(constrain_45(o, nan).x.is_nan()); + } +} diff --git a/crates/ui-egui/tests/masks_ui.rs b/crates/ui-egui/tests/masks_ui.rs index b958f9409..08934963f 100644 --- a/crates/ui-egui/tests/masks_ui.rs +++ b/crates/ui-egui/tests/masks_ui.rs @@ -216,6 +216,50 @@ fn create_and_edit_masks_on_the_monitor() { d.shot("mask-tracked"); } +/// Shift held while placing pen points snaps each new segment to 45° steps from the previous +/// point; a Shift-drag also constrains the Bézier handle it pulls out (#853). +#[test] +fn shift_constrains_pen_mask_segments_to_45_degrees() { + let mut d = Driver::demo(); + d.ok("ui.set", json!({"workspace": "Effects"})); + let seq = d.exec("sequence.inspect", json!({})); + let clip = &seq["video"][0]["items"][0]; + let (id, start, dur) = (clip["clip"].as_u64().unwrap(), clip["start"].as_i64().unwrap(), clip["duration"].as_i64().unwrap()); + d.exec("playhead.set", json!({"time": start + dur / 2})); + d.exec("timeline.select", json!({"clips": [id]})); + d.frames(3); + d.ok("ui.click", json!({"id": "effectControls.opacity.mask.pen"})); + d.frames(2); + let pic = d.rect("program.picture"); + let at = |fx: f64, fy: f64| json!({"x": pic[0] + pic[2] * fx, "y": pic[1] + pic[3] * fy}); + // hold Shift (as the OS reports a held key) across the clicks + d.harness.input_mut().events.push(egui::Event::ModifiersChanged(egui::Modifiers::SHIFT)); + d.frames(1); + for (fx, fy) in [(0.3, 0.3), (0.6, 0.34), (0.63, 0.6)] { + d.ok("ui.click", at(fx, fy)); + d.frames(2); + } + // a Shift-drag places the fourth point and pulls out its handle + d.ok("ui.drag", json!({"from": at(0.32, 0.62), "to": at(0.42, 0.64), "steps": 6, "modifiers": {"shift": true}})); + d.frames(2); + d.harness.input_mut().events.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + d.frames(1); + d.ok("ui.click", at(0.3, 0.3)); + d.frames(3); + let m = d.masks(); + let pen = m.iter().find(|x| x["effectId"] == "opacity").unwrap_or_else(|| panic!("opacity mask: {m:?}")); + assert_eq!(pen["path"]["vertices"].as_array().unwrap().len(), 4, "{pen}"); + let (p0, p1, p2, p3) = (v0(pen, 0), v0(pen, 1), v0(pen, 2), v0(pen, 3)); + assert!((p1.1 - p0.1).abs() < 1e-6, "segment 0→1 horizontal: {p0:?} {p1:?}"); + assert!(p1.0 > p0.0 + 50.0, "{p0:?} {p1:?}"); + assert!((p2.0 - p1.0).abs() < 1e-6, "segment 1→2 vertical: {p1:?} {p2:?}"); + assert!(p2.1 > p1.1 + 50.0, "{p1:?} {p2:?}"); + assert!((p3.1 - p2.1).abs() < 1e-6, "segment 2→3 horizontal: {p2:?} {p3:?}"); + let out = &pen["path"]["vertices"][3]["out"]; + let (ox, oy) = (out[0].as_f64().unwrap(), out[1].as_f64().unwrap()); + assert!(ox > 10.0 && oy.abs() < 1e-6, "handle constrained horizontal: {pen}"); +} + #[test] fn presets_bin_drag_and_save_dialog() { let mut d = Driver::demo(); diff --git a/docs/gaps.md b/docs/gaps.md index 564541067..866ba40e7 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -235,13 +235,14 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** - **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 (constrained lines; an on-monitor corner-roundness handle). + redesigned 26.0 mask tools (Shift-constrained vertex and whole-mask drags; 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); 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); rectangle and polygon masks round their corners with circular fillets (`masks.add` `cornerRadius`, `masks.roundCorners`, every path keyframe; - #813). + #813); Shift snaps pen mask segments and dragged-out Bézier handles to 45° steps (#853). - **Estimate:** 60–110 h. ## G19. Speech to text off in release builds From b964b9970a8286b141ed081f4db904fbcc77c05e Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 11:16:50 +1100 Subject: [PATCH 159/260] #856: Reject negative times on timeline.place and markers.add Clips and markers before sequence start were accepted, then unreachable in the GUI and cut off on export. Time, frame and seconds before 0 now return a parameter error; zero remains valid. --- crates/engine/src/commands.rs | 13 ++++++++++-- crates/engine/src/sequence_extras.rs | 4 ++-- crates/engine/src/sequence_tools.rs | 6 +++--- crates/engine/src/tests.rs | 31 ++++++++++++++++++++++++++++ 4 files changed, 47 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 6609afddf..6f7d25226 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -239,6 +239,15 @@ pub(crate) fn time_p(s: &Session, p: &Value, prefix: &str) -> Option { None } +/// Sequence time from params. Times before 0 are unreachable in the GUI and cut off on export. +pub(crate) fn sequence_time_p(s: &Session, p: &Value, prefix: &str, cmd: &str) -> Result> { + let Some(t) = time_p(s, p, prefix) else { return Ok(None) }; + if t < Tick::ZERO { + return Err(bad(cmd, "time must not be negative")); + } + Ok(Some(t)) +} + pub(crate) fn clip_p(p: &Value, k: &str) -> Option { u64_p(p, k).map(ClipId) } @@ -1999,7 +2008,7 @@ fn build() -> Vec { return Ok(v); } let (_seq, ph) = marker_list_mut(s)?; - let t = time_p(s, p, "").unwrap_or(ph); + let t = sequence_time_p(s, p, "", "markers.add")?.unwrap_or(ph); let name = str_p(p, "name").unwrap_or("").to_string(); let comment = str_p(p, "comment").unwrap_or("").to_string(); let color = str_p(p, "color").and_then(Label::from_name).unwrap_or(Label::Green); @@ -2200,7 +2209,7 @@ fn build() -> Vec { return Err(bad("timeline.place", "enable video, audio, or both")); } let item = item_p(p, "item").ok_or_else(|| bad("timeline.place", "need `item`"))?; - let at = time_p(s, p, "").unwrap_or(s.playhead()); + let at = sequence_time_p(s, p, "", "timeline.place")?.unwrap_or(s.playhead()); let tg = s.targeting(); let pi = s.project.item(item).ok_or_else(|| bad("timeline.place", "no such item"))?; let is_audio = |t: TrackId| s.active_sequence().is_some_and(|q| q.audio_tracks.iter().any(|x| x.id == t)); diff --git a/crates/engine/src/sequence_extras.rs b/crates/engine/src/sequence_extras.rs index 389590c81..8b5d8e6d8 100644 --- a/crates/engine/src/sequence_extras.rs +++ b/crates/engine/src/sequence_extras.rs @@ -20,7 +20,7 @@ use filmcraft_project::{ClipId, ItemKind, Label, Marker, MarkerId, MarkerKind, S use filmcraft_time::{Tick, TimeRange}; use serde_json::{Value, json}; -use crate::commands::{CommandSpec, bad, bool_p, f64_p, has_seq, str_p, time_p, u64_p}; +use crate::commands::{CommandSpec, bad, bool_p, f64_p, has_seq, sequence_time_p, str_p, u64_p}; use crate::{EngineError, Result, Session}; type Run = fn(&mut Session, &Value) -> Result; @@ -483,7 +483,7 @@ fn show_active_only(s: &mut Session, p: &Value) -> Result { // --------------------------------------------------------------------------------------------- fn add_flash_cue(s: &mut Session, p: &Value) -> Result { - let t = s.sequence_rate().snap(time_p(s, p, "").unwrap_or(s.playhead())); + let t = s.sequence_rate().snap(sequence_time_p(s, p, "", "markers.addFlashCue")?.unwrap_or(s.playhead())); let n = s.active_sequence().map(|q| q.markers.iter().filter(|m| m.kind == MarkerKind::FlashCue).count()).unwrap_or(0) + 1; let name = str_p(p, "name").map(str::to_string).unwrap_or_else(|| format!("Cue Point {n}")); let comment = str_p(p, "comment").unwrap_or("").to_string(); diff --git a/crates/engine/src/sequence_tools.rs b/crates/engine/src/sequence_tools.rs index dc6c57953..897b91125 100644 --- a/crates/engine/src/sequence_tools.rs +++ b/crates/engine/src/sequence_tools.rs @@ -31,7 +31,7 @@ use filmcraft_project::{AudioChannels, ClipId, ItemId, Label, Marker, MarkerId, use filmcraft_time::{Tick, TimeRange}; use serde_json::{Value, json}; -use crate::commands::{CommandSpec, always, bad, bool_p, clips_p, has_seq, str_p, time_p, track_p, with_links}; +use crate::commands::{CommandSpec, always, bad, bool_p, clips_p, has_seq, sequence_time_p, str_p, time_p, track_p, with_links}; use crate::{EngineError, Result, Session}; type Run = fn(&mut Session, &Value) -> Result; @@ -206,7 +206,7 @@ fn groups() -> Vec<(&'static str, Vec)> { r#"{"time":ticks?,"name":str?,"comment":str?,"color":label?}"#, has_seq, |s, p| { - let t = time_p(s, p, "").unwrap_or(s.playhead()); + let t = sequence_time_p(s, p, "", "markers.addChapter")?.unwrap_or(s.playhead()); let n = s.active_sequence().map(|q| q.markers.iter().filter(|m| m.kind == MarkerKind::Chapter).count()).unwrap_or(0) + 1; let name = str_p(p, "name").map(str::to_string).unwrap_or_else(|| format!("Chapter {n}")); let id = add_marker(s, "Add Chapter Marker", t, Tick::ZERO, MarkerKind::Chapter, name, p)?; @@ -977,7 +977,7 @@ fn add_marker(s: &mut Session, label: &str, t: Tick, dur: Tick, kind: MarkerKind /// `duration` say otherwise) at the playhead. fn add_range_marker(s: &mut Session, p: &Value) -> Result { let rate = s.sequence_rate(); - let t = time_p(s, p, "").unwrap_or(s.playhead()); + let t = sequence_time_p(s, p, "", "markers.addRange")?.unwrap_or(s.playhead()); let dur = p .get("duration") .and_then(Value::as_i64) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9852734d7..3b01ca7db 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -958,3 +958,34 @@ fn enable_flips_each_selected_clip() { s.execute("clip.enable", json!({"clips": [a, b]})).unwrap(); assert_eq!((enabled(&s, a), enabled(&s, b)), (true, true)); } + +/// Times before sequence start are unreachable in the GUI and cut off on export. +#[test] +fn negative_place_and_marker_times_are_refused() { + let mut s = demo(); + let item = s.active_sequence().unwrap().video_tracks[0].items[0].item; + let before = s.project.clone(); + let n_markers = s.active_sequence().unwrap().markers.len(); + let n_clips: usize = s.active_sequence().unwrap().video_tracks.iter().map(|t| t.items.len()).sum(); + for (cmd, params) in [ + ("timeline.place", json!({"item": item.0, "frame": -12})), + ("timeline.place", json!({"item": item.0, "seconds": -1.0})), + ("timeline.place", json!({"item": item.0, "time": -1})), + ("markers.add", json!({"frame": -10})), + ("markers.add", json!({"seconds": -0.5})), + ("markers.add", json!({"time": i64::MIN})), + ("markers.addChapter", json!({"frame": -1})), + ("markers.addRange", json!({"time": -1, "durationFrames": 10})), + ("markers.addFlashCue", json!({"frame": -3})), + ] { + let e = s.execute(cmd, params.clone()).expect_err(&format!("{cmd} {params}")); + assert!(matches!(&e, EngineError::BadParams { cmd: c, msg } if c == cmd && msg.contains("negative")), "{cmd} {params}: {e:?}"); + assert_eq!(*s.project, *before); + } + assert_eq!(s.active_sequence().unwrap().markers.len(), n_markers); + let n_clips_after: usize = s.active_sequence().unwrap().video_tracks.iter().map(|t| t.items.len()).sum(); + assert_eq!(n_clips_after, n_clips); + s.execute("timeline.place", json!({"item": item.0, "frame": 0})).unwrap(); + s.execute("markers.add", json!({"frame": 0, "name": "start"})).unwrap(); + assert!(s.active_sequence().unwrap().markers.iter().any(|m| m.start == Tick::ZERO && m.name == "start")); +} From 111991591b9f6a185c7cd759745243ad6170e9b4 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 11:17:47 +1100 Subject: [PATCH 160/260] Roll linked audio with the picture cut (#855). timeline.roll follows linked partners at the same through-edit when linked selection is on, so a +12 frame roll on V1 also moves A1. The command reports linked:false when the cut is not followed as a pair. Reversed clips keep the unmoved-edge media mapping (#526/#558). --- crates/engine/src/commands.rs | 63 ++++++++++++++++++- crates/engine/src/lib.rs | 2 + crates/engine/src/roll_linked_tests.rs | 86 ++++++++++++++++++++++++++ crates/ui-egui/src/panels/timeline.rs | 13 ++-- 4 files changed, 157 insertions(+), 7 deletions(-) create mode 100644 crates/engine/src/roll_linked_tests.rs diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 6609afddf..2e76582f9 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -411,6 +411,47 @@ pub(crate) fn slip_together(q: &mut filmcraft_project::Sequence, ctx: &mut edit: Ok(()) } +/// Adjacent roll pairs that follow `left`/`right` when linked selection is on: the given cut, plus +/// through-edits between the same pieces' linked partners at that same time (Premiere linked +/// selection). Split edits (audio cut at another time) and unlinked clips are not added. +pub fn linked_roll_pairs(seq: &filmcraft_project::Sequence, left: ClipId, right: ClipId, follow_links: bool) -> Vec<(ClipId, ClipId)> { + let mut pairs = vec![(left, right)]; + if !follow_links { + return pairs; + } + let Some((_, l)) = seq.find_item(left) else { + return pairs; + }; + let Some((_, r)) = seq.find_item(right) else { + return pairs; + }; + let cut = l.end(); + let left_link = l.link; + let right_link = r.link; + if left_link.is_none() && right_link.is_none() { + return pairs; + } + for tr in seq.all_tracks() { + if tr.locked { + continue; + } + for w in tr.items.windows(2) { + if w[0].id == left && w[1].id == right { + continue; + } + if w[0].end() != w[1].start || w[0].end() != cut { + continue; + } + let left_ok = left_link.is_some_and(|g| w[0].link == Some(g)); + let right_ok = right_link.is_some_and(|g| w[1].link == Some(g)); + if left_ok && right_ok { + pairs.push((w[0].id, w[1].id)); + } + } + } + pairs +} + /// 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() }; @@ -2412,8 +2453,26 @@ fn build() -> Vec { .map(Tick) .or_else(|| p.get("deltaFrames").and_then(Value::as_i64).map(|f| s.sequence_rate().tick_of(f))) .unwrap_or_default(); - let x = s.edit_sequence("Rolling Edit", |q, ctx, _| Ok(edit::roll(q, l, r, d, ctx)?))?; - Ok(json!({"delta": x.0})) + let follow = s.state.linked_selection; + let (x, n, linked) = s.edit_sequence("Rolling Edit", |q, ctx, _| { + let pairs = linked_roll_pairs(q, l, r, follow); + let linked = pairs.len() > 1; + let mut d = d; + for &(pl, pr) in &pairs { + let mut probe = q.clone(); + let x = edit::roll(&mut probe, pl, pr, d, ctx)?; + if x.abs() < d.abs() { + d = x; + } + } + if d != Tick::ZERO { + for &(pl, pr) in &pairs { + edit::roll(q, pl, pr, d, ctx)?; + } + } + Ok((d, pairs.len(), linked)) + })?; + Ok(json!({"delta": x.0, "pairs": n, "linked": linked})) }), cmd!("timeline.slip", "Slip", [], None, r#"{"clip":id,"delta":ticks|"deltaFrames":i64}"#, has_seq, |s, p| { let c = clip_p(p, "clip").ok_or_else(|| bad("timeline.slip", "need `clip`"))?; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a2ed9e6a2..2d9a84261 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1156,6 +1156,8 @@ mod remix_tests; #[cfg(test)] mod ripple_delete_tests; #[cfg(test)] +mod roll_linked_tests; +#[cfg(test)] mod scopes_tests; #[cfg(test)] mod sequence_inspect_tests; diff --git a/crates/engine/src/roll_linked_tests.rs b/crates/engine/src/roll_linked_tests.rs new file mode 100644 index 000000000..dd3f2e4b2 --- /dev/null +++ b/crates/engine/src/roll_linked_tests.rs @@ -0,0 +1,86 @@ +//! #855: a rolling edit on a linked A/V cut must move the sound cut with the picture. + +use filmcraft_project::ClipId; +use filmcraft_time::Tick; +use serde_json::json; + +use crate::Session; + +fn range(s: &Session, c: ClipId) -> (Tick, Tick) { + s.active_sequence().unwrap().find_item(c).map(|(_, i)| (i.start, i.end())).unwrap() +} + +fn source_in(s: &Session, c: ClipId) -> Tick { + s.active_sequence().unwrap().find_item(c).unwrap().1.source_in +} + +/// Two consecutive 48-frame A/V clips (picture on V1, linked sound on A1). +fn two_clips(s: &mut Session) -> [(ClipId, ClipId); 2] { + s.execute("file.openDemoProject", json!({})).unwrap(); + s.execute("file.newSequence", json!({"name": "Roll", "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 n in 0..2 { + s.execute("timeline.place", json!({"item": item.0, "frame": 48 * n, "sourceIn": rate.tick_of(48).0, "duration": rate.tick_of(48).0})).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)) +} + +/// #855: rolling the V1 cut +12 frames used to leave A1, so export played the second clip's audio +/// under the first clip's picture. +#[test] +fn roll_moves_linked_audio_cut() { + let mut s = Session::default(); + let [(v0, a0), (v1, a1)] = two_clips(&mut s); + assert!(s.state.linked_selection); + let rate = s.sequence_rate(); + let f = |n: i64| rate.tick_of(n); + let r = s.execute("timeline.roll", json!({"left": v0.0, "right": v1.0, "deltaFrames": 12})).unwrap(); + assert_eq!(r["linked"], json!(true), "{r}"); + assert_eq!(r["pairs"], json!(2), "{r}"); + assert_eq!(range(&s, v0), (f(0), f(60))); + assert_eq!(range(&s, v1), (f(60), f(96))); + assert_eq!(range(&s, a0), (f(0), f(60)), "linked sound must roll with the picture"); + assert_eq!(range(&s, a1), (f(60), f(96))); +} + +/// Linked selection off (or unlinked clips) rolls only the given pair and says so. +#[test] +fn roll_without_linked_selection_leaves_audio() { + let mut s = Session::default(); + let [(v0, a0), (v1, a1)] = two_clips(&mut s); + s.execute("sequence.linkedSelection", json!({"on": false})).unwrap(); + let rate = s.sequence_rate(); + let f = |n: i64| rate.tick_of(n); + let r = s.execute("timeline.roll", json!({"left": v0.0, "right": v1.0, "deltaFrames": 12})).unwrap(); + assert_eq!(r["linked"], json!(false), "{r}: clips were not followed as a linked pair"); + assert_eq!(range(&s, v0), (f(0), f(60))); + assert_eq!(range(&s, v1), (f(60), f(96))); + assert_eq!(range(&s, a0), (f(0), f(48)), "unlinked A1 cut stays"); + assert_eq!(range(&s, a1), (f(48), f(96))); +} + +/// #526/#558: reversed linked clips still keep the media on the unmoved timeline edges. +#[test] +fn roll_linked_reversed_clips_keeps_media_mapping() { + let mut s = Session::default(); + let [(v0, a0), (v1, a1)] = two_clips(&mut s); + s.execute("timeline.select", json!({"clips": [v0.0, a0.0, v1.0, a1.0]})).unwrap(); + s.execute("clip.speedDuration", json!({"speed": 100, "reverse": true})).unwrap(); + let rate = s.sequence_rate(); + let f = |n: i64| rate.tick_of(n); + let left_in = source_in(&s, v0); + let right_in = source_in(&s, v1); + s.execute("timeline.roll", json!({"left": v0.0, "right": v1.0, "deltaFrames": 12})).unwrap(); + assert_eq!(range(&s, v0), (f(0), f(60))); + assert_eq!(range(&s, a0), (f(0), f(60))); + assert_eq!(range(&s, v1), (f(60), f(96))); + assert_eq!(range(&s, a1), (f(60), f(96))); + // reversed left: source_in moves so the clip's In still shows the same media + assert_eq!(source_in(&s, v0), left_in - f(12)); + assert_eq!(source_in(&s, a0), left_in - f(12)); + // reversed right: source_in stays so the clip's Out still shows the same media + assert_eq!(source_in(&s, v1), right_in); + assert_eq!(source_in(&s, a1), right_in); +} diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 70cde2b69..a2e3dc874 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1680,11 +1680,14 @@ fn preview_drag(app: &FilmcraftApp, seq: &Sequence, out: &mut HashMap { - if let Some((_, l)) = seq.find_item(*left) { - moved(l, filmcraft_edit::Edge::Out, *delta); - } - if let Some((_, r)) = seq.find_item(*right) { - moved(r, filmcraft_edit::Edge::In, *delta); + let pairs = filmcraft_engine::commands::linked_roll_pairs(seq, *left, *right, app.session.state.linked_selection); + for (pl, pr) in pairs { + if let Some((_, l)) = seq.find_item(pl) { + moved(l, filmcraft_edit::Edge::Out, *delta); + } + if let Some((_, r)) = seq.find_item(pr) { + moved(r, filmcraft_edit::Edge::In, *delta); + } } } Drag::Slide { clip, delta } => { From 7625ddf4de80c9c2b2fbce3bb5bbc9e37040114d Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 02:22:09 +0200 Subject: [PATCH 161/260] #854: GIF frame delays add up to the sequence duration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: the GIF exporter gave every frame the same delay of 1000·den/num ms, which the encoder truncates to whole centiseconds. At 24 fps each 41.67 ms frame became 4 cs, so a 4.0 s sequence played in 3.84 s (4 % fast); other non-integer rates drifted the same way. Fix: each frame's delay now runs from its rounded start time to the next frame's (gif_delay_cs), so delays alternate (4, 4, 5, 4, 4, 4 cs at 24 fps) and add up to the exact duration. Checked i128 math also replaces the unchecked `(1000 * den) as u32` cast. Co-Authored-By: Claude Opus 5.5 --- crates/export/src/lib.rs | 17 +++++++++++++++-- crates/export/src/tests.rs | 25 +++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index e9d5b1285..d6a26863f 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -1671,6 +1671,19 @@ pub fn frame_span(rate: FrameRate, range: TimeRange) -> (i64, i64) { (f0, f1.max(f0)) } +/// The delay of the `i`th GIF frame (from the start of the export) in centiseconds, the unit GIF +/// stores. Each frame runs from its rounded start time to the next frame's, so the delays add up +/// to the exact duration (24 fps: 4, 4, 5, 4, 4, 4 cs …) instead of every frame truncating +/// 4.17 cs to 4 cs and the animation playing 4 % fast (#854). +fn gif_delay_cs(rate: FrameRate, i: i64) -> u16 { + let num = i128::from(rate.num.max(1)); + let den = i128::from(rate.den.max(0)); + // start of frame `k` in centiseconds, rounded half up + let at = |k: i64| i128::from(k.max(0)).saturating_mul(200).saturating_mul(den).saturating_add(num) / num.saturating_mul(2); + let cs = at(i.saturating_add(1)).saturating_sub(at(i)); + u16::try_from(cs.clamp(0, i128::from(u16::MAX))).unwrap_or(u16::MAX) +} + /// An in-memory writer that stays reachable after the encoder that owns a clone is dropped. #[derive(Clone, Default)] struct SharedBuf(Arc>>); @@ -1809,7 +1822,6 @@ pub fn export(project: &Arc, seq: ItemId, settings: &ExportSettings, so } else { None }; - let delay = image::Delay::from_numer_denom_ms((1000 * pipe.rate.den) as u32, pipe.rate.num as u32); let mut f = f0; while f < f1 { if cancelled() { @@ -1818,8 +1830,9 @@ pub fn export(project: &Arc, seq: ItemId, settings: &ExportSettings, so let end = (f + batch).min(f1); if let Some(enc) = gif.as_mut() { let frames: Vec> = (f..end).into_par_iter().map(|fi| pipe.frame(fi, sources).0).collect(); - for rgba in frames { + for (fi, rgba) in (f..end).zip(frames) { let img = image::RgbaImage::from_raw(w, h, rgba).ok_or_else(|| ExportError::Encode("frame".into()))?; + let delay = image::Delay::from_numer_denom_ms(u32::from(gif_delay_cs(pipe.rate, fi - f0)) * 10, 1); enc.encode_frame(image::Frame::from_parts(img, 0, 0, delay)).map_err(|e| ExportError::Encode(e.to_string()))?; } } else { diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs index 3b0d6bca5..9417a7fda 100644 --- a/crates/export/src/tests.rs +++ b/crates/export/src/tests.rs @@ -89,6 +89,31 @@ fn wav_png_gif() { assert!(std::path::Path::new(&tmp("seq005.png")).exists()); } +/// GIF delays are whole centiseconds (#854): at 24 fps they alternate 4/4/5 cs so a second of +/// frames lasts 1000 ms, not 24 × 40 ms = 960 ms. +#[test] +fn gif_frame_delays_add_up_to_the_duration() { + let d: Vec = (0..6).map(|i| gif_delay_cs(FrameRate::FPS_24, i)).collect(); + assert_eq!(d, [4, 4, 5, 4, 4, 4]); + let ntsc: u64 = (0..30).map(|i| u64::from(gif_delay_cs(FrameRate::FPS_29_97, i))).sum(); + assert_eq!(ntsc, 100, "30 frames at 29.97 fps last 1.001 s"); + // hostile rates neither panic nor overflow + assert_eq!(gif_delay_cs(FrameRate { num: 0, den: 0 }, 3), 0); + let _ = gif_delay_cs(FrameRate { num: 1, den: i64::MAX }, i64::MAX); + + let (p, seq, m) = project(); + let path = tmp("timing.gif"); + let mut s = ExportSettings { format: Format::Gif, path: path.clone(), scale: 0.25, ..Default::default() }; + s.range = Some(TimeRange::new(Tick::ZERO, FrameRate::FPS_24.tick_of(24))); + export(&p, seq, &s, &m, &Progress::default()).unwrap(); + use image::AnimationDecoder; + let dec = image::codecs::gif::GifDecoder::new(std::io::Cursor::new(std::fs::read(&path).unwrap())).unwrap(); + let frames = dec.into_frames().collect_frames().unwrap(); + assert_eq!(frames.len(), 24); + let ms: u32 = frames.iter().map(|f| f.delay().numer_denom_ms()).map(|(n, d)| n / d.max(1)).sum(); + assert_eq!(ms, 1000); +} + #[test] fn prores_export_roundtrip() { let (p, seq, m) = project(); From f9f7ea466660b67b5a063cff90d58d4e58932cac Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 11:35:59 +1100 Subject: [PATCH 162/260] #848: LongGOP seek / time-to-first-frame, not decoder throughput MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin GOP-cache eviction on the requested pts so a burst past the playhead does not drop the target and walk the GOP again. Preroll only schedules the frames that gate clock start. bench_playback no longer meters preroll jobs as on-clock latency. This is the seek stall from #848, not the ~3× per-core gap vs ffmpeg. --- crates/codecs/src/gop.rs | 105 +++++++++++++++++++++- crates/ui-egui/examples/bench/playback.rs | 15 ++-- crates/ui-egui/src/frames.rs | 4 +- 3 files changed, 116 insertions(+), 8 deletions(-) diff --git a/crates/codecs/src/gop.rs b/crates/codecs/src/gop.rs index c1395e430..6f0025c59 100644 --- a/crates/codecs/src/gop.rs +++ b/crates/codecs/src/gop.rs @@ -152,6 +152,10 @@ struct State { bytes: usize, /// When a frame was last asked of this cache. last_used: Instant, + /// Presentation pts of the frame the last request wanted: eviction keeps a window around it + /// (not around the newest decoded picture). Walking a long GOP dumps pictures in decode + /// order; a window around those would throw away the playhead frame and force another walk. + pin: i64, /// Only background requests (thumbnails, [`filmcraft_media::cancel::with_background`]) have /// used the decoder since it was made: it is given up as soon as another background request /// is done, whatever the caches' recency. @@ -457,6 +461,7 @@ impl GopCache { drafts: Default::default(), bytes: 0, last_used: Instant::now(), + pin: i64::MIN, background: false, }), pool: pool.clone(), @@ -509,10 +514,19 @@ impl GopCache { st.bytes -= old.byte_size(); filmcraft_frame::pool::recycle(old); } - // evict frames far from the most recent (keep a window around the working position) + // Evict frames far from the request (pin), not the newest decode. A frame-threaded + // decoder can emit a burst past the playhead; pivoting on those pts used to drop the + // wanted picture and walk the GOP again. + let pivot = if st.pin != i64::MIN { st.pin } else { pts }; while st.bytes > budget && st.frames.len() > 2 { let (Some(&first), Some(&last)) = (st.frames.keys().next(), st.frames.keys().next_back()) else { break }; - let victim = if pts - first > last - pts { first } else { last }; + let mut victim = if pivot - first > last - pivot { first } else { last }; + if victim == pivot || victim == pts { + victim = if victim == first { last } else { first }; + if victim == pivot || victim == pts { + break; + } + } if let Some(v) = st.frames.remove(&victim) { st.drafts.remove(&victim); st.bytes -= v.byte_size(); @@ -583,6 +597,7 @@ impl GopCache { let mut st = self.shared.state.lock().unwrap_or_else(|e| e.into_inner()); let _decoding = DecodingGuard::enter(me); st.last_used = Instant::now(); + st.pin = want_pts; let background = filmcraft_media::cancel::background(); if let Some(f) = st.cached(want_pts, draft).cloned() { HITS.fetch_add(1, Ordering::Relaxed); @@ -1364,4 +1379,90 @@ mod tests { } assert_eq!(s.resets.load(Ordering::Relaxed), 1); } + + /// Many pictures in one GOP; a decoder that emits a burst past the wanted sample (frame- + /// threaded H.264). Eviction used to pivot on the newest pts and drop the playhead frame. + struct Burst { + n: usize, + /// Pictures held before a decode dumps them all. + burst: usize, + held: VecDeque, + resets: Arc, + decodes: Arc, + } + + impl VideoDecoder for Burst { + fn decode(&mut self, _sample: &[u8], pts: i64) -> crate::Result> { + self.decodes.fetch_add(1, Ordering::Relaxed); + self.held.push_back(pts); + if self.held.len() < self.burst { + return Ok(Vec::new()); + } + Ok(self.held.drain(..).map(|p| picture((p, false))).collect()) + } + fn flush(&mut self) -> Vec { + self.held.drain(..).map(|p| picture((p, false))).collect() + } + fn reset(&mut self) { + self.resets.fetch_add(1, Ordering::Relaxed); + self.held.clear(); + } + fn name(&self) -> &str { + "burst" + } + } + + impl VideoSamples for Burst { + fn count(&self) -> usize { + self.n + } + fn pts(&self, i: usize) -> i64 { + i as i64 * 1000 + } + fn sync_before(&self, _i: usize) -> usize { + 0 + } + fn sample_at(&self, t: i64) -> Option { + let i = (t / 1000) as usize; + (i < self.n).then_some(i) + } + fn read(&self, i: usize) -> crate::Result> { + Ok(vec![i as u8]) + } + fn make_decoder(&self) -> crate::Result> { + Ok(Box::new(Burst { n: self.n, burst: self.burst, held: VecDeque::new(), resets: self.resets.clone(), decodes: self.decodes.clone() })) + } + } + + #[test] + fn long_gop_seek_keeps_the_target_and_does_not_walk_twice() { + let s = Burst { n: 200, burst: 90, held: VecDeque::new(), resets: Default::default(), decodes: Default::default() }; + let mut c = GopCache::new(None); + c.budget = 0; + // Want a picture early in the GOP; the burst dumps 90 pictures at once (past MIN_FRAMES). + assert_eq!(index_of(&c.frame(&s, 10_000).expect("frame")), 10); + assert_eq!(s.resets.load(Ordering::Relaxed), 1); + let decodes = s.decodes.load(Ordering::Relaxed); + assert!(decodes <= 90 + 1, "walked {decodes} samples"); + // The wanted picture is still cached: asking again must not restart or re-feed. + assert_eq!(index_of(&c.frame(&s, 10_000).expect("cached")), 10); + assert_eq!(s.resets.load(Ordering::Relaxed), 1); + assert_eq!(s.decodes.load(Ordering::Relaxed), decodes); + // Playback continues from the same run. + assert_eq!(index_of(&c.frame(&s, 11_000).expect("frame")), 11); + assert_eq!(s.resets.load(Ordering::Relaxed), 1); + } + + #[test] + fn long_gop_continue_does_not_restart_for_the_next_frame() { + let s = samples(300, 250, 8, false); + let mut c = GopCache::new(None); + c.budget = 0; + assert_eq!(index_of(&c.frame(&s, 80_000).expect("frame")), 80); + let resets = s.resets.load(Ordering::Relaxed); + let decodes = s.decodes.load(Ordering::Relaxed); + assert_eq!(index_of(&c.frame(&s, 81_000).expect("frame")), 81); + assert_eq!(s.resets.load(Ordering::Relaxed), resets); + assert!(s.decodes.load(Ordering::Relaxed) - decodes <= 8 + 2); + } } diff --git a/crates/ui-egui/examples/bench/playback.rs b/crates/ui-egui/examples/bench/playback.rs index dee93ea05..94f56d103 100644 --- a/crates/ui-egui/examples/bench/playback.rs +++ b/crates/ui-egui/examples/bench/playback.rs @@ -347,11 +347,16 @@ impl Bench { } } for r in &recs { - rep.latency.push(ms(r.finished - r.queued)); - rep.service.push(ms(r.finished - r.started)); - rep.job_cpu.push(ms(r.cpu)); - rep.src_wall.push(ms(r.source_wall)); - rep.src_cpu.push(ms(r.source_cpu)); + // Preroll jobs run before the play clock; counting them as on-clock latency made + // the LongGOP seek look like a mid-play stall (`lat.max` ≈ `preroll_ms`). + let on_clock = r.queued >= anchor && due_at.contains_key(&r.key.frame) && r.key.target == target; + if on_clock { + rep.latency.push(ms(r.finished - r.queued)); + rep.service.push(ms(r.finished - r.started)); + rep.job_cpu.push(ms(r.cpu)); + rep.src_wall.push(ms(r.source_wall)); + rep.src_cpu.push(ms(r.source_cpu)); + } if !due_at.contains_key(&r.key.frame) || r.key.target != target { rep.wasted_jobs += 1; } diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index c8db19cdd..947b4713f 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -585,7 +585,9 @@ impl FrameServer { // the frame due now; once the clock runs, the frames before it are late self.request_job(key, rate.tick_of(key.frame), scale, project, 0, false, (!preroll && dir > 0).then_some(Tick::ZERO)); } - let ahead = prefetch_depth(speed); + // During preroll the clock has not started: only the frames that gate start, not a + // prefetch run that walks the rest of a LongGOP and evicts the playhead pictures. + let ahead = if preroll { (PREROLL_FRAMES - 1).max(0) } else { prefetch_depth(speed) }; for i in 0..ahead { let f = key.frame + (lead + i * stride) * dir * step; if f >= 0 { From 615b89a36402ce0926498e862ef298970f2a726d Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 02:41:31 +0200 Subject: [PATCH 163/260] #867: Indonesian (id) interface translation Problem: Indonesian, one of the 12 key languages in docs/localization-parity.md, had no catalog (0%), so Indonesian users got the interface in English and System Language never picked Indonesian. Fix: add crates/ui-egui/src/i18n/id.tsv, clean-room translations of every entry in the Spanish catalog plus the native macOS Settings menu (3,541), with a glossary in its header. Language::Id ("Bahasa Indonesia") is persisted as `id`; it appears in Edit > Language (also the native macOS menu), Settings > General, and System Language picks it for `id-*` locales and the legacy `in-*` code older Java/Android runtimes report. Tests cover the core menus, reject entries without a Spanish counterpart, check glyph coverage with the bundled fonts, switching/persistence and locale matching. Attribution sidecar, ATTRIBUTION.md row, docs (gaps.md G14, localization-parity, ROADMAP, ui-egui README) updated. 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 | 6 + crates/ui-egui/README.md | 2 +- crates/ui-egui/src/i18n/id.tsv | 3564 ++++++++++++++++++++ crates/ui-egui/src/i18n/id.tsv.attribution | 8 + crates/ui-egui/src/i18n/mod.rs | 93 +- crates/ui-egui/src/menus.rs | 4 + docs/gaps.md | 5 +- docs/localization-parity.md | 4 +- 12 files changed, 3685 insertions(+), 9 deletions(-) create mode 100644 crates/ui-egui/src/i18n/id.tsv create mode 100644 crates/ui-egui/src/i18n/id.tsv.attribution diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 0c1793ed1..d6c293d2a 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/id.tsv` | FilmCraft contributors | Original work: clean-room Indonesian interface translations | MIT OR Apache-2.0 | | `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 | diff --git a/ROADMAP.md b/ROADMAP.md index 9566bc599..eff1587fd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -80,7 +80,7 @@ Premiere ships 10 interface languages. Measured from our catalogs (3,498 strings | Arabic | none (no RTL interface) | 0% | | French | partial (whole interface; native review welcome) | ~100% | | Portuguese (Brazil) | menus only | 9.9% | -| Indonesian | none | 0% | +| Indonesian | partial (whole interface; native review welcome) | ~100% | | Japanese | partial (whole interface) | 99.7% | | German | none | 0% | | Korean | none | 0% | diff --git a/apps/filmcraft/src/native_menu.rs b/apps/filmcraft/src/native_menu.rs index 61241a8a2..de4591449 100644 --- a/apps/filmcraft/src/native_menu.rs +++ b/apps/filmcraft/src/native_menu.rs @@ -109,6 +109,8 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver, Sho filmcraft_ui_egui::i18n::Language::Ru } else if checked("app.language.french") { filmcraft_ui_egui::i18n::Language::Fr + } else if checked("app.language.indonesian") { + filmcraft_ui_egui::i18n::Language::Id } else { filmcraft_ui_egui::i18n::Language::En }; diff --git a/crates/engine/src/settings.rs b/crates/engine/src/settings.rs index e6f91d785..00d83f442 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`, `ru`, `fr`, or `system` (the default: the operating system's preferred language when the + /// `zh-cn`, `de`, `ru`, `fr`, `id`, 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`. @@ -777,6 +777,7 @@ static CATEGORIES: &[Category] = &[ ("de", "Deutsch"), ("ru", "Русский"), ("fr", "Français"), + ("id", "Bahasa Indonesia"), ]), true, ), diff --git a/crates/engine/src/settings_tests.rs b/crates/engine/src/settings_tests.rs index 6df9e2462..c78344057 100644 --- a/crates/engine/src/settings_tests.rs +++ b/crates/engine/src/settings_tests.rs @@ -38,6 +38,12 @@ fn interface_language_persists_and_rejects_hostile_values() { 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"); + // Indonesian is stored as `id`, not a locale tag or the legacy `in` code + for value in [json!("id-ID"), json!("in"), json!("ID")] { + assert!(s.execute("prefs.set", json!({"key": "general.interfaceLanguage", "value": value})).is_err()); + } + set(&mut s, "general.interfaceLanguage", json!("id")); + assert_eq!(Preferences::load(&path).general.interface_language, "id"); // 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 69ccdc1e3..ec6964093 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, German, Russian and French. The language is stored in the engine's `general.interfaceLanguage` preference, +Chinese, German, Russian, French and Indonesian (which covers the whole interface). 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 diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv new file mode 100644 index 000000000..3f082d1c3 --- /dev/null +++ b/crates/ui-egui/src/i18n/id.tsv @@ -0,0 +1,3564 @@ +# Indonesian (id) 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 +# Indonesian (Bahasa Indonesia) video-editing vocabulary. No proprietary localisation resource, +# string table or product UI was consulted or copied; add entries the same way. Commands use the +# base verb ("Simpan Sebagai…", "Tambah Penanda"), messages address the user as "Anda", and menu +# labels keep the title case of the English source. Product, format and technology names +# (FilmCraft, ArtCraft, Lumetri, ProRes, LUFS, EDL, AAF…) keep their spelling, and common loanwords +# of the trade (render, preset, mixer, gain, ripple, roll, slip, slide, snap, scrub, offline) stay +# as they are. Decimal fractions in descriptions use a comma (29,97 fps). 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 = klip, track = trek, sequence = sekuens, timeline = linimasa, bin = bin, playhead = +# kepala putar, In/Out = Masuk / Keluar (titik masuk / titik keluar), marker = penanda, keyframe = +# bingkai kunci, frame = bingkai, frame hold = tahan bingkai, edit point = titik edit, trim = +# pangkas, panel = panel, workspace = ruang kerja, captions = takarir, subtitle = subtitel, proxy = +# proksi, insert = sisipkan, overwrite = timpa, lift = angkat, extract = ekstrak, nest = sarangkan, +# footage = rekaman, waveform = bentuk gelombang, scopes = skop, blur = buram, dissolve = larut, +# wipe = sapuan, glow = pendar, noise = derau, error = galat, file = berkas, guide = garis bantu, +# thumbnail = gambar mini, voice-over = sulih suara, default = bawaan, preferences (also the native +# macOS Settings menu) = Preferensi, settings = pengaturan. + + About FilmCraft Tentang FilmCraft + Add Audio Submix Track Tambah Trek Submix Audio + Add Caption at Playhead Tambah Takarir di Kepala Putar + Add Chapter Marker… Tambah Penanda Bab… + Add Edit Tambah Titik Edit + Add Edit to All Tracks Tambah Titik Edit ke Semua Trek + Add Flash Cue Marker… Tambah Penanda Isyarat Flash… + Add Frame Hold Tambah Tahan Bingkai + Add Guide… Tambah Garis Bantu… + Add Marker Tambah Penanda + Add New Caption Track… Tambah Trek Takarir Baru… + Add Range Marker Tambah Penanda Rentang + Add Range Marker to In and Out Tambah Penanda Rentang dari Masuk ke Keluar + Add Tracks… Tambah Trek… + Adjustment Layer… Lapisan Penyesuaian… + Align to Selection Ratakan ke Pilihan + Align to Video Frame Ratakan ke Bingkai Video + Align to Video Frame as Group Ratakan ke Bingkai Video sebagai Grup + All Panels Semua Panel + Alpha Alfa + Appearance Penampilan + Appearance… Penampilan… + Apply Audio Transition Terapkan Transisi Audio + Apply Default Transitions to Selection Terapkan Transisi Bawaan ke Pilihan + Apply Video Transition Terapkan Transisi Video + Arrange Susun + ArtCraft Website Situs Web ArtCraft + Assembly Perakitan + Attach Proxies… Lampirkan Proksi… + Audio Channels… Kanal Audio… + Audio Clip Mixer Mixer Klip Audio + Audio Gain… Gain Audio… + Audio Hardware… Perangkat Keras Audio… + Audio In Audio Masuk + Audio Meters Meter Audio + Audio Options Opsi Audio + Audio Out Audio Keluar + Audio Track Mixer Mixer Trek Audio + Audio Waveform Bentuk Gelombang Audio + Auto Save… Simpan Otomatis… + Automate to Sequence… Otomatiskan ke Sekuens… + Bars and Tone… Bar dan Nada… + Bin Bin + Bin From Selection Bin dari Pilihan + Black Video… Video Hitam… + Blue Biru + Bottom Bawah + Breakout to Mono Pecah ke Mono + Bring Forward Bawa Maju + Bring to Front Bawa ke Depan + Brown Cokelat + Captions Takarir + Captions and Graphics Takarir dan Grafis + Captions… Takarir… + Caribbean Karibia + Center Horizontally Tengahkan Secara Horizontal + Center Vertically Tengahkan Secara Vertikal + Cerulean Biru Langit + Clear Bersihkan + Clear Guides Hapus Garis Bantu + Clear In Hapus Masuk + Clear In and Out Hapus Masuk dan Keluar + Clear Markers Hapus Penanda + Clear Out Hapus Keluar + Clear Selected Marker Hapus Penanda Terpilih + Close Tutup + Close All Other Projects Tutup Semua Proyek Lain + Close All Projects Tutup Semua Proyek + Close Gap Tutup Celah + Close Project Tutup Proyek + Color Management… Manajemen Warna… + Color Matte… Matte Warna… + Comparison View Tampilan Perbandingan + Composite Video Video Komposit + Consolidate Duplicates Gabungkan Duplikat + Copy Salin + Copy Paste Includes Sequence Markers Salin Tempel Menyertakan Penanda Sekuens + Create Captions from Transcript… Buat Takarir dari Transkrip… + Create Multi-Camera Source Sequence… Buat Sekuens Sumber Multi-Kamera… + Create Proxies… Buat Proksi… + Cut Potong + Darkest Tergelap + Delete Render Files Hapus Berkas Render + Delete Render Files In to Out Hapus Berkas Render dari Masuk ke Keluar + Delete Tracks… Hapus Trek… + Delete Transcript Hapus Transkrip + Demo Footage Rekaman Demo + Demo Project Proyek Demo + Deselect All Batalkan Semua Pilihan + Detach Proxies Lepaskan Proksi + Display Mode Mode Tampilan + Distribute Distribusikan + Distribute Horizontally Distribusikan Secara Horizontal + Distribute Space Horizontally Distribusikan Jarak Secara Horizontal + Distribute Space Vertically Distribusikan Jarak Secara Vertikal + Distribute Vertically Distribusikan Secara Vertikal + Duplicate Duplikat + Dynamic Audio Waveforms Bentuk Gelombang Audio Dinamis + Edit Edit + Edit Offline… Edit Offline… + Edit Original Edit Asli + Edit Subclip… Edit Subklip… + Editing Penyuntingan + Effect Controls Kontrol Efek + Effects Efek + Ellipse Elips + Enable Aktifkan + Enable Remix Aktifkan Remix + Essential Graphics Grafis Esensial + Essential Sound Suara Esensial + Events Peristiwa + Export Ekspor + Export As Motion Graphics Template… Ekspor sebagai Templat Grafis Gerak… + Extract Ekstrak + Extract Audio Ekstrak Audio + Field Options… Opsi Field… + File Berkas + File… Berkas… + Fill frame Isi bingkai + FilmCraft Help… Bantuan FilmCraft… + FilmCraft on GitHub FilmCraft di GitHub + FilmCraft on getartcraft.com FilmCraft di getartcraft.com + Find Next Cari Berikutnya + Find… Cari… + Fit Pas + Fit to frame Paskan ke bingkai + Flatten Ratakan + Forest Hijau Hutan + Frame Blending Pencampuran Bingkai + Frame Hold Options… Opsi Tahan Bingkai… + Frame Sampling Pengambilan Sampel Bingkai + From Bin Dari Bin + From Source Monitor Dari Monitor Sumber + From Source Monitor, Match Frame Dari Monitor Sumber, Cocokkan Bingkai + From file… Dari berkas… + Full Penuh + Generate Audio Waveform Buat Bentuk Gelombang Audio + Get Media File Properties for Dapatkan Properti Berkas Media untuk + Go to Gap Ke Celah + Go to In Ke Masuk + Go to Next Caption Segment Ke Segmen Takarir Berikutnya + Go to Next Marker Ke Penanda Berikutnya + Go to Out Ke Keluar + Go to Previous Caption Segment Ke Segmen Takarir Sebelumnya + Go to Previous Marker Ke Penanda Sebelumnya + Go to Split Ke Split + Graphics and Titles Grafis dan Judul + Graphics… Grafis… + Green Hijau + Group Grupkan + Guide Templates Templat Garis Bantu + Help Bantuan + Hide All Caption Tracks Sembunyikan Semua Trek Takarir + High Quality Playback Pemutaran Kualitas Tinggi + History Riwayat + Import From Impor Dari + Import Image Sequence… Impor Urutan Gambar… + Import from Media Browser Impor dari Peramban Media + Import… Impor… + Info Info + Ingest Settings… Pengaturan Ingest… + Insert Sisipkan + Insert Frame Hold Segment Sisipkan Segmen Tahan Bingkai + Install Motion Graphics Template… Pasang Templat Grafis Gerak… + Interpret Footage… Interpretasikan Rekaman… + Join the ArtCraft Discord… Gabung ke Discord ArtCraft… + Keyboard Shortcuts… Pintasan Keyboard… + Label Label + Labels… Label… + Language Bahasa + Lavender Lavender + Learning Belajar + Left Kiri + Libraries Pustaka + Lift Angkat + Light Terang + Link Tautkan + Link Media… Tautkan Media… + Refresh Changed Media Segarkan Media yang Berubah + Linked Selection Pilihan Tertaut + Lock Guides Kunci Garis Bantu + Lumetri Color Lumetri Color + Lumetri Scopes Lumetri Scopes + Magnification Pembesaran + Make Offline… Jadikan Offline… + Make Subclip… Buat Subklip… + Make Subsequence Buat Subsekuens + Manage Guides… Kelola Garis Bantu… + Mark Clip Tandai Klip + Mark In Tandai Masuk + Mark Out Tandai Keluar + Mark Selection Tandai Pilihan + Mark Split Tandai Split + Markers Penanda + Match Frame Cocokkan Bingkai + Media Analysis & Transcription… Analisis Media & Transkripsi… + Media Browser Peramban Media + Media Cache… Cache Media… + Media… Media… + Medium Sedang + Memory… Memori… + Merge Clips… Gabungkan Klip… + Metadata Metadata + Modify Ubah + Motion Graphics Template… Templat Grafis Gerak… + Multi-Camera Multi-Kamera + Multi-Camera View Tampilan Multi-Kamera + Nest… Sarangkan… + New Baru + New Layer Lapisan Baru + New Project Panel Panel Proyek Baru + Next in Sequence Berikutnya di Sekuens + Next in Track Berikutnya di Trek + Normalize Mix Track… Normalisasi Trek Mix… + Offline File… Berkas Offline… + Open Project… Buka Proyek… + Optical Flow Aliran Optik + Overwrite Timpa + Paste Tempel + Paste Attributes… Tempel Atribut… + Paste Insert Tempel Sisip + Paused Resolution Resolusi Saat Jeda + Playback Resolution Resolusi Pemutaran + Playback… Pemutaran… + Plugins… Plugin… + Polygon Poligon + Preferences Preferensi + Previous in Sequence Sebelumnya di Sekuens + Previous in Track Sebelumnya di Trek + Program Program + Progress Kemajuan + Project Proyek + Project Manager… Manajer Proyek… + Project Settings Pengaturan Proyek + Project… Proyek… + Properties Properti + Purple Ungu + Reconnect Full Resolution Media… Sambungkan Ulang Media Resolusi Penuh… + Recover Unsaved Changes… Pulihkan Perubahan yang Belum Disimpan… + Rectangle Persegi Panjang + Red Merah + Redo Ulangi + Reference Monitor Monitor Referensi + Remix Remix + Remix Properties… Properti Remix… + Remove Attributes… Hapus Atribut… + Remove Filler Words Hapus Kata Pengisi + Remove Pauses Hapus Jeda + Remove Unused Hapus yang Tidak Dipakai + Rename… Ganti Nama… + Render Audio Render Audio + Render Effects In to Out Render Efek dari Masuk ke Keluar + Render In to Out Render dari Masuk ke Keluar + Render Selection Render Pilihan + Replace Fonts in Projects… Ganti Font di Proyek… + Replace With Clip Ganti dengan Klip + Report an Issue… Laporkan Masalah… + Reset All Parameters Atur Ulang Semua Parameter + Reset Duration Atur Ulang Durasi + Reset to Saved Layout Kembalikan ke Tata Letak Tersimpan + Restore Captions from Source Clip Pulihkan Takarir dari Klip Sumber + Reveal Log Files… Tampilkan Berkas Log… + Reverse Match Frame Cocokkan Bingkai Terbalik + Revert Kembalikan + Revert Remix Kembalikan Remix + Review Tinjauan + Right Kanan + Ripple Delete Hapus Ripple + Ripple Sequence Markers Ripple Penanda Sekuens + Rose Merah Mawar + Safe Margins Margin Aman + Save Simpan + Save All Simpan Semua + Save As… Simpan Sebagai… + Save Guides as Template… Simpan Garis Bantu sebagai Templat… + Save a Copy… Simpan Salinan… + Save as Template… Simpan sebagai Templat… + Scale to Frame Size Skalakan ke Ukuran Bingkai + Scene Edit Detection… Deteksi Pergantian Adegan… + Scratch Disks… Disk Kerja… + Search Bin Bin Pencarian + Select Pilih + Select All Pilih Semua + Select All Matching Pilih Semua yang Cocok + Select Label Group Pilih Grup Label + Select Next Graphic Pilih Grafis Berikutnya + Select Next Layer Pilih Lapisan Berikutnya + Select Previous Graphic Pilih Grafis Sebelumnya + Select Previous Layer Pilih Lapisan Sebelumnya + Selection Follows Playhead Pilihan Mengikuti Kepala Putar + Selection as FilmCraft Project… Pilihan sebagai Proyek FilmCraft… + Selection… Pilihan… + Send Backward Kirim Mundur + Send to Back Kirim ke Belakang + Sequence Sekuens + Sequence From Clip Sekuens dari Klip + Sequence Settings… Pengaturan Sekuens… + Sequence… Sekuens… + Show Active Caption Tracks Only Tampilkan Hanya Trek Takarir Aktif + Show All Caption Tracks Tampilkan Semua Trek Takarir + Show All Marker Colors Tampilkan Semua Warna Penanda + Show Guides Tampilkan Garis Bantu + Show Rulers Tampilkan Penggaris + Show Through Edits Tampilkan Edit Tembus + Simplify Sequence… Sederhanakan Sekuens… + Snap in Program Monitor Snap di Monitor Program + Snap in Timeline Snap di Linimasa + Source Sumber + Source Settings… Pengaturan Sumber… + Speed/Duration… Kecepatan/Durasi… + Synchronize… Sinkronkan… + System Compatibility Report… Laporan Kompatibilitas Sistem… + Tan Cokelat Muda + Teal Hijau Toska + Text Teks + Time Interpolation Interpolasi Waktu + Timecode Kode Waktu + Timecode… Kode Waktu… + Timeline Linimasa + Timeline… Linimasa… + Toggle Proxies Alihkan Proksi + Tools Alat + Top Atas + Transcribe Sequence… Transkripsikan Sekuens… + Transcribe… Transkripsikan… + Transcript Transkrip + Transparent Video… Video Transparan… + Trim Edit Edit Pangkas + Trim… Pangkas… + Undo Urungkan + Ungroup Pisahkan Grup + Universal Counting Leader… Leader Hitung Mundur Universal… + Update Metadata… Perbarui Metadata… + Upgrade Caption to Graphic Tingkatkan Takarir menjadi Grafis + Upgrade to Source Graphic Tingkatkan menjadi Grafis Sumber + Vertical Text Teks Vertikal + Video In Video Masuk + Video Options Opsi Video + Video Out Video Keluar + Video and Audio Waveform Split Video dan Bentuk Gelombang Audio Terpisah + View Tampilan + Violet Violet + Window Jendela + Workspaces Ruang Kerja + Yellow Kuning + Zoom In Perbesar + Zoom Out Perkecil + Zoom to Sequence Zoom ke Sekuens + Home Beranda + Import Impor + {name} - Edited {name} - Diedit + Full screen Layar penuh + Volume Volume + Master volume: use the Audio Track Mixer Volume master: gunakan Mixer Trek Audio + Search Cari + Quick Export Ekspor Cepat + Join the ArtCraft Discord (discord.gg/artcraft) Gabung ke Discord ArtCraft (discord.gg/artcraft) + Cancel Batal + OK OK + Interpret Footage Interpretasikan Rekaman + Color Management · {n} item Manajemen Warna · {n} item + Color Management · {n} items Manajemen Warna · {n} item + Media colour space (from file metadata): {detected} Ruang warna media (dari metadata berkas): {detected} + Override media colour space: Timpa ruang warna media: + Use file metadata Gunakan metadata berkas + Sequence Color Management Manajemen Warna Sekuens + Working Color Space Ruang Warna Kerja + Wide gamut color (composite in BT.2020) Warna gamut lebar (komposit dalam BT.2020) + Auto tone map media (HDR and log into SDR) Pemetaan nada otomatis media (HDR dan log ke SDR) + Audio Gain Gain Audio + Set Gain to: Atur Gain ke: + Adjust Gain by: Sesuaikan Gain sebesar: + Normalize Max Peak to: Normalisasi Puncak Maks ke: + Normalize All Peaks to: Normalisasi Semua Puncak ke: + Peak Amplitude: {peak} Amplitudo Puncak: {peak} + Standard Standar + Adaptive Adaptif + Mono Mono + Stereo Stereo + Add Tracks Tambah Trek + Amount Jumlah + Placement Penempatan + Track type Jenis trek + Add video tracks Tambah trek video + Add audio tracks Tambah trek audio + Add audio submix tracks Tambah trek submix audio + After {name} Setelah {name} + Before First Track Sebelum Trek Pertama + Delete Tracks Hapus Trek + Video Tracks Trek Video + Audio Tracks Trek Audio + Delete Video Tracks Hapus Trek Video + Delete Audio Tracks Hapus Trek Audio + All Empty Tracks Semua Trek Kosong + Caption Tracks Trek Takarir + Delete Caption Tracks Hapus Trek Takarir + Delete Caption Track Hapus Trek Takarir + Delete Empty Caption Tracks Hapus Trek Takarir Kosong + Version {version} Versi {version} + A clean-room, pure-Rust non-linear video editor. Part of the ArtCraft family. Editor video non-linear clean-room berbahasa Rust murni. Bagian dari keluarga ArtCraft. + Join the ArtCraft Discord Gabung ke Discord ArtCraft + Source code on GitHub Kode sumber di GitHub + Report an issue Laporkan masalah + MIT OR Apache-2.0. Fonts: Inter, Noto Serif and JetBrains Mono (SIL OFL 1.1). MIT OR Apache-2.0. Font: Inter, Noto Serif dan JetBrains Mono (SIL OFL 1.1). + just now baru saja + {n} s ago {n} dtk lalu + {n} min ago {n} mnt lalu + {n} h ago {n} jam lalu + All Events Semua Peristiwa + Warnings and Errors Peringatan dan Galat + Errors Only Hanya Galat + Clear All Hapus Semua + No events. Tidak ada peristiwa. + Show finished jobs Tampilkan tugas yang selesai + Cancelled Dibatalkan + Failed: {e} Gagal: {e} + Done Selesai + No background jobs. Tidak ada tugas latar belakang. + Recover Unsaved Changes Pulihkan Perubahan yang Belum Disimpan + Untitled Tanpa Judul + FilmCraft was closed while “{name}” had unsaved changes. FilmCraft ditutup saat “{name}” masih memiliki perubahan yang belum disimpan. + FilmCraft quit unexpectedly while “{name}” had unsaved changes. FilmCraft keluar secara tak terduga saat “{name}” masih memiliki perubahan yang belum disimpan. + Recover unsaved changes from {when}? Pulihkan perubahan yang belum disimpan dari {when}? + The project had not been saved yet. Proyek ini belum pernah disimpan. + {n} sessions have unsaved changes: {n} sesi memiliki perubahan yang belum disimpan: + Discard Buang + Recover Pulihkan + Not Now Jangan Sekarang + Are you sure you want to discard your changes to '{file}'? Yakin ingin membuang perubahan Anda pada '{file}'? + Yes Ya + No Tidak + Add media to your project. Drop files anywhere in the window, or choose a source below. Tambahkan media ke proyek Anda. Letakkan berkas di mana saja di jendela, atau pilih sumber di bawah. + Browse files… Telusuri berkas… + Movies, audio and stills from disk Film, audio, dan gambar diam dari disk + Demo footage Rekaman demo + Six procedural 1080p clips with sound Enam klip prosedural 1080p dengan suara + Demo project Proyek demo + A cut sequence with transitions, effects and music Sekuens yang sudah dipotong dengan transisi, efek, dan musik + Bars and Tone Bar dan Nada + SMPTE HD bars with 1 kHz reference tone Bar SMPTE HD dengan nada referensi 1 kHz + Community Komunitas + Join us on Discord Bergabunglah dengan kami di Discord + OMF Export Settings Pengaturan Ekspor OMF + AAF Export Settings Pengaturan Ekspor AAF + OMF Title: Judul OMF: + Mixdown video Mixdown video + Audio: Audio: + Encapsulate Enkapsulasi + Separate Audio Audio Terpisah + Embed Sematkan + Separate Files Berkas Terpisah + Link to Media Tautkan ke Media + Audio File Format: Format Berkas Audio: + Sample Rate: Laju Sampel: + Bits per Sample: Bit per Sampel: + Rendering: Rendering: + Trim audio files Pangkas berkas audio + Handle Frames: Bingkai Handle: + Render audio clip effects Render efek klip audio + Breakout to mono Pecah ke mono + Exported {path} Diekspor: {path} + Select a clip to view its metadata. Pilih klip untuk melihat metadatanya. + Clip: {name} Klip: {name} + Open Buka + (no sequence) (tanpa sekuens) + Show or hide {color} markers Tampilkan atau sembunyikan penanda {color} + Marker Penanda + {tc} Chapter {tc} Bab + No markers. Press M to add one. Tidak ada penanda. Tekan M untuk menambahkan. + All markers are hidden by the colour filter. Semua penanda disembunyikan oleh filter warna. + Type: Jenis: + Video: Video: + Codec: Codec: + Duration: Durasi: + Settings: Pengaturan: + Playhead: Kepala putar: + Video {n}: Video {n}: + Audio {n}: Audio {n}: + UI: UI: + {fps} fps · {n} frames queued {fps} fps · {n} bingkai dalam antrean + {panel} — coming in a later milestone {panel} — hadir di milestone berikutnya + Close Panel Tutup Panel + Close Other Timeline Panels Tutup Panel Linimasa Lain + Close All Tutup Semua + Maximize Frame Maksimalkan Bingkai + Restore Frame Pulihkan Bingkai + Restore Workspace Pulihkan Ruang Kerja + Reveal Sequence in Project Tampilkan Sekuens di Proyek + Video Thumbnails Gambar Mini Video + Audio Waveforms Bentuk Gelombang Audio + Delete Preset Hapus Preset + Export Preset… Ekspor Preset… + Import Presets… Impor Preset… + Effect presets Preset efek + Save Preset Simpan Preset + Name: Nama: + Type Jenis + Scale Skala + Anchor to In Point Jangkarkan ke Titik Masuk + Anchor to Out Point Jangkarkan ke Titik Keluar + Without keyframes Tanpa bingkai kunci + Description: Deskripsi: + Saved preset “{name}” Preset “{name}” disimpan + Current duration {seconds} s Durasi saat ini {seconds} dtk + Remix Properties Properti Remix + Target Duration: Durasi Target: + Segments: Segmen: + Fewer · More Lebih Sedikit · Lebih Banyak + Variations: Variasi: + Remixed to {seconds} s Di-remix menjadi {seconds} dtk + {mode} · no clip {mode} · tanpa klip + {mode} · no sequence {mode} · tanpa sekuens + Remove Row Hapus Baris + Add a timecode row Tambah baris kode waktu + Panner Panner + Mute Bisukan + Send {n} Level Level Kirim {n} + Show Keyframes Tampilkan Bingkai Kunci + Clip Keyframes Bingkai Kunci Klip + Track Keyframes Bingkai Kunci Trek + Delete Hapus + Untitled Workspace Ruang Kerja Tanpa Judul + New Workspace Ruang Kerja Baru + Edit Workspaces Edit Ruang Kerja + Rename Ganti Nama + Restore Original Pulihkan Asli + Search effects Cari efek + Apply to Selected Clips Terapkan ke Klip Terpilih + Metadata Display Tampilan Metadata + Save As New View Preset Simpan sebagai Preset Tampilan Baru + Manage Saved View Presets Kelola Preset Tampilan Tersimpan + Freeform View Options Opsi Tampilan Bebas + Save Arrangement Simpan Susunan + Saves the view, columns, sort, thumbnail and font size. Menyimpan tampilan, kolom, urutan, gambar mini, dan ukuran font. + Grid spacing: Jarak kisi: + Default clip size: Ukuran klip bawaan: + Snap to grid Snap ke kisi + Show clip names Tampilkan nama klip + Show durations Tampilkan durasi + Show: Tampilkan: + Search fields Bidang pencarian + Project Metadata Metadata Proyek + Add Property… Tambah Properti… + Column order Urutan kolom + {n}. (empty) {n}. (kosong) + Restore Pulihkan + Overwrite with Current View Timpa dengan Tampilan Saat Ini + Graphics text search arrives with M10.1–M10.2 Pencarian teks grafis hadir pada M10.1–M10.2 + Open a sequence to work with captions Buka sekuens untuk bekerja dengan takarir + Add captions Tambah takarir + Create a caption track or import a caption file. Buat trek takarir atau impor berkas takarir. + Create new caption track Buat trek takarir baru + Import captions file… Impor berkas takarir… + New {format} track Trek {format} baru + Add caption at playhead Tambah takarir di kepala putar + Split caption at playhead Pisahkan takarir di kepala putar + Merge selected captions Gabungkan takarir terpilih + Delete selected captions Hapus takarir terpilih + No captions on this track. Press + to add one at the playhead. Tidak ada takarir di trek ini. Tekan + untuk menambahkan di kepala putar. + No matching captions. Tidak ada takarir yang cocok. + Open a sequence to see its transcript Buka sekuens untuk melihat transkripnya + Transcribe sequence Transkripsikan sekuens + Speech-to-text turns the dialogue into editable text. Ucapan-ke-teks mengubah dialog menjadi teks yang dapat diedit. + This build has no speech-to-text; import a transcript with transcript.set. Build ini tidak memiliki ucapan-ke-teks; impor transkrip dengan transcript.set. + Transcribe Transkripsikan + Extract selected text Ekstrak teks terpilih + Lift selected text Angkat teks terpilih + Remove filler words Hapus kata pengisi + Create captions Buat takarir + Speaker Pembicara + Box Kotak + Align Ratakan + Align left Rata kiri + Align center Rata tengah + Align right Rata kanan + Position Posisi + Position: top Posisi: atas + Position: middle Posisi: tengah + Position: bottom Posisi: bawah + (gap) (celah) + Out: {name} Keluar: {name} + In: {name} Masuk: {name} + Play Around Edit (Space loops, Shift+K once) Putar di Sekitar Edit (Space berulang, Shift+K sekali) + Trim Backward Many Pangkas Mundur Banyak + Trim Backward Pangkas Mundur + Apply Default Transitions Terapkan Transisi Bawaan + Apply Default Transitions to Selection (Shift+D) Terapkan Transisi Bawaan ke Pilihan (Shift+D) + Trim Forward Pangkas Maju + Trim Forward Many Pangkas Maju Banyak + Exit Trim Mode Keluar dari Mode Pangkas + Voice-over record (right-click: Voice-Over Record Settings…) Rekam sulih suara (klik kanan: Pengaturan Rekam Sulih Suara…) + Voice-Over Record Settings… Pengaturan Rekam Sulih Suara… + Recording voice-over… Merekam sulih suara… + Voice-over recorded: {p} Sulih suara direkam: {p} + Voice-over recording stopped (nothing recorded) Perekaman sulih suara dihentikan (tidak ada yang direkam) + Post-roll Post-roll + ● Recording ● Merekam + Voice-Over Record Settings Pengaturan Rekam Sulih Suara + Source: Sumber: + Default Input Input Bawaan + Input: Input: + Channel {n} Kanal {n} + Countdown Sound Cues Isyarat Suara Hitung Mundur + Pre-roll: Pre-roll: + seconds detik + 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. Pemutaran memulai pre-roll sebelum kepala putar (atau titik Masuk); jika Masuk/Keluar ditandai, perekaman dimulai dan berhenti di sana. + Effect Efek + Clip Fx Editor Editor Fx Klip + Track Fx Editor Editor Fx Trek + Master Gain Gain Master + Reset Atur Ulang + Band {n} Pita {n} + Solo Solo + Bypass Lewati + Thr Amb + Ratio Rasio + Att Atk + Rel Lps + Gain Gain + Output Gain Gain Output + Limiter Limiter + Link Channels Tautkan Kanal + Limiter Threshold Ambang Limiter + Limiter Release Pelepasan Limiter + Auto Gate Gate Otomatis + Threshold Ambang + Attack Serangan + Release Pelepasan + Hold Tahan + Compressor Kompresor + Auto Makeup Makeup Otomatis + Makeup Makeup + Expander Ekspander + Soft Clip Soft Clip + (no sequences) (tidak ada sekuens) + (no clips) (tidak ada klip) + Show Transport Controls Tampilkan Kontrol Transport + Loop Putar Berulang + Multi-Camera Audio Follows Video Audio Multi-Kamera Mengikuti Video + Multi-Camera Record Rekam Multi-Kamera + Multi-Camera Selection Top Down Pilihan Multi-Kamera dari Atas ke Bawah + Show Multi-Camera Preview Monitor Tampilkan Monitor Pratinjau Multi-Kamera + Auto-Adjust Multi-Camera Playback Quality Sesuaikan Otomatis Kualitas Pemutaran Multi-Kamera + Transmit Multi-Camera View Kirim Tampilan Multi-Kamera + Multi-Camera Layout Tata Letak Multi-Kamera + Automatic Otomatis + Edit Cameras… Edit Kamera… + Add Marker (M) Tambah Penanda (M) + Mark In (I) Tandai Masuk (I) + Mark Out (O) Tandai Keluar (O) + Go to In (Shift+I) Ke Masuk (Shift+I) + Step Back 1 Frame (Left) Mundur 1 Bingkai (Kiri) + Play-Stop Toggle (Space) Putar/Berhenti (Space) + Step Forward 1 Frame (Right) Maju 1 Bingkai (Kanan) + Go to Out (Shift+O) Ke Keluar (Shift+O) + Insert (,) Sisipkan (,) + Overwrite (.) Timpa (.) + Export Frame (Shift+E) Ekspor Bingkai (Shift+E) + Lift (;) Angkat (;) + Extract (') Ekstrak (') + Export Frame: use Export mode (M6) Ekspor Bingkai: gunakan mode Ekspor (M6) + Button Editor Editor Tombol + Multi-Camera Record on Rekam Multi-Kamera aktif + Multi-Camera Record off Rekam Multi-Kamera nonaktif + {first} - Merged {first} - Digabung + {first} Multicam {first} Multikamera + In Points Titik Masuk + Out Points Titik Keluar + Clip Marker Penanda Klip + Audio Audio + Merge Clips Gabungkan Klip + Create Multi-Camera Source Sequence Buat Sekuens Sumber Multi-Kamera + Synchronize Clips Sinkronkan Klip + Clip Name: Nama Klip: + Sequence Name: Nama Sekuens: + {n} clip(s): {names} {n} klip: {names} + {n} clip(s) selected; the clip on the reference track stays put. {n} klip dipilih; klip di trek referensi tetap di tempatnya. + Synchronize Point Titik Sinkronisasi + Ignore Hours Abaikan Jam + first marker penanda pertama + Offset Audio by: Geser Audio sebesar: + Offset: Offset: + frames bingkai + Reference track: Trek referensi: + lowest (V1…) terendah (V1…) + Remove Audio from AV Clip Hapus Audio dari Klip AV + Camera 1 Kamera 1 + All Cameras Semua Kamera + Switch Audio Ganti Audio + Camera Names Nama Kamera + Clip Names Nama Klip + Enumerate Cameras Nomori Kamera + Camera Angle Metadata Metadata Sudut Kamera + Move source clips to Processed Clips bin Pindahkan klip sumber ke bin Klip Terproses + Synchronized {n} clip group(s) {n} grup klip disinkronkan + Merged clip created Klip gabungan dibuat + Multi-camera source sequence with {n} camera(s) Sekuens sumber multi-kamera dengan {n} kamera + No multi-camera clip at the playhead Tidak ada klip multi-kamera di kepala putar + Camera {n} — {name} Kamera {n} — {name} + Page {page} of {pages} Halaman {page} dari {pages} + Previous page Halaman sebelumnya + Next page Halaman berikutnya + Multi-Camera Record On/Off (0) Rekam Multi-Kamera Aktif/Nonaktif (0) + Record Rekam + Edit Cameras Edit Kamera + Cameras shown in the Multi-Camera view (uncheck to hide). Kamera yang ditampilkan di tampilan Multi-Kamera (hapus centang untuk menyembunyikan). + Recorded {n} multi-camera cut(s) {n} potongan multi-kamera direkam + Clamp Signal Batasi Sinyal + Presets Preset + Parade ({kind}) Parade ({kind}) + Waveform ({kind}) Bentuk Gelombang ({kind}) + Parade Type Jenis Parade + Waveform Type Jenis Bentuk Gelombang + Vectorscope Targets Target Vektorskop + Colour Space Ruang Warna + Brightness Kecerahan + Signal Scale Skala Sinyal + Histogram Histogram + Paste Attributes Tempel Atribut + Remove Attributes Hapus Atribut + New Offline File Berkas Offline Baru + Nested Sequence Name Nama Sekuens Bersarang + Make Subclip Buat Subklip + Edit Subclip Edit Subklip + Modify Clip: Audio Channels Ubah Klip: Kanal Audio + Modify Clip: Timecode Ubah Klip: Kode Waktu + Frame Hold Options Opsi Tahan Bingkai + Field Options Opsi Field + Save Project Simpan Proyek + Motion Gerak + Opacity Opasitas + Time Remapping Pemetaan Ulang Waktu + Channel Volume Volume Kanal + In Point Titik Masuk + Out Point Titik Keluar + Playhead Kepala Putar + Source Timecode Kode Waktu Sumber + Sequence Time Waktu Sekuens + Video Attributes Atribut Video + Audio Attributes Atribut Audio + No effects Tidak ada efek + Scale Attribute Times Skalakan Waktu Atribut + File Name: Nama Berkas: + Tape Name: Nama Pita: + Contains: Berisi: + Video Video + Width: Lebar: + Height: Tinggi: + Frame Rate: Laju Bingkai: + Media Start: Awal Media: + Subclip: {name} Subklip: {name} + Start: Mulai: + End: Akhir: + Restrict Trims To Subclip Boundaries Batasi Pemangkasan ke Batas Subklip + Convert to Master Clip Konversi ke Klip Master + Clip Channel Format: Format Kanal Klip: + Number of Audio Clips: Jumlah Klip Audio: + Media Source Channels Kanal Sumber Media + Ch. {n} Kan. {n} + Clip {n} Klip {n} + Source channels the selected audio clips play: Kanal sumber yang diputar klip audio terpilih: + Set Timecode: Atur Kode Waktu: + Use the file's timecode Gunakan kode waktu berkas + Hold On Tahan Pada + Timecode: Kode Waktu: + Hold Filters Tahan Filter + Reverse Field Dominance Balik Dominasi Field + Processing Options Opsi Pemrosesan + None Tidak Ada + Always Deinterlace Selalu Deinterlace + Flicker Removal Penghilangan Kedip + Save changes to “{project_name}” before closing? Simpan perubahan pada “{project_name}” sebelum menutup? + Don't Save Jangan Simpan + All media is online. Semua media online. + {name} (copy) {name} (salinan) + Offline Offline + Missing Hilang + Link Media Tautkan Media + {n} clip(s) can't find their media. Locate them, search a folder, or leave them offline. {n} klip tidak dapat menemukan medianya. Temukan, cari di folder, atau biarkan offline. + Clip Name Nama Klip + File Name Nama Berkas + File Path Jalur Berkas + Status Status + Match file properties: Cocokkan properti berkas: + File Extension Ekstensi Berkas + Clip ID (fingerprint) ID Klip (sidik jari) + Duration Durasi + Media Start Awal Media + Frame Size / Rate Ukuran / Laju Bingkai + Align Timecode Selaraskan Kode Waktu + Relink others automatically Tautkan ulang yang lain secara otomatis + Search in: Cari di: + Browse… Telusuri… + Exact name matches only Hanya nama yang sama persis + No candidates yet — Search a folder or Locate the file. Belum ada kandidat — Cari di folder atau Temukan berkasnya. + ✔ same file ✔ berkas sama + ✔ matches ✔ cocok + ✖ different file ✖ berkas berbeda + Preview Pratinjau + Offline All Jadikan Semua Offline + Locate… Temukan… + Linked {n} clip(s) {n} klip ditautkan + Nothing found with that name. Tidak ditemukan apa pun dengan nama itu. + Make Offline Jadikan Offline + Make {n} selected clip(s) offline. Their clips show the offline slate until you link them again. Jadikan {n} klip terpilih offline. Klip tersebut menampilkan slate offline sampai Anda menautkannya lagi. + Media files remain on disk Berkas media tetap ada di disk + Media files are deleted Berkas media dihapus + Create Proxies Buat Proksi + {n} clip(s) selected. {n} klip dipilih. + Format and size Format dan ukuran + Destination Tujuan + Next to the original media, in a Proxies folder Di samping media asli, dalam folder Proxies + Proxies are made in the background and attached when done. Export always uses full-resolution media. Proksi dibuat di latar belakang dan dilampirkan setelah selesai. Ekspor selalu menggunakan media resolusi penuh. + Creating {n} proxy file(s)… Membuat {n} berkas proksi… + Project Manager Manajer Proyek + Sequences Sekuens + Resulting Project Proyek Hasil + Collect Files and Copy to New Location Kumpulkan Berkas dan Salin ke Lokasi Baru + Consolidate and Transcode Konsolidasi dan Transkode + Options Opsi + Exclude Unused Clips Kecualikan Klip yang Tidak Dipakai + Include Handles: Sertakan Handle: + Include Proxies Sertakan Proksi + Include Preview Files Sertakan Berkas Pratinjau + Destination Path Jalur Tujuan + Disk space: original {original} · resulting {result} ({n} files) Ruang disk: asli {original} · hasil {result} ({n} berkas) + Disk space: — Ruang disk: — + Calculate Hitung + Project Manager: writing {path} Manajer Proyek: menulis {path} + Timeline: (no sequences) Linimasa: (tidak ada sekuens) + Browse Telusuri + Select audio clips, then click a preset to apply it. Pilih klip audio, lalu klik preset untuk menerapkannya. + {name} (user) {name} (pengguna) + Select audio clips to edit them here Pilih klip audio untuk mengeditnya di sini + Multiple Clips Selected Beberapa Klip Dipilih + The selected clips have different audio types. Assign one type to edit them together. Klip terpilih memiliki jenis audio yang berbeda. Tetapkan satu jenis untuk mengeditnya bersama. + Assign an audio type to the selection: Tetapkan jenis audio untuk pilihan: + Clear Audio Type Hapus Jenis Audio + Preset: Preset: + (Custom) (Kustom) + Preset name Nama preset + Reduce Noise Kurangi Derau + Reduce Rumble Kurangi Gemuruh + DeHum DeHum + DeEss DeEss + Reduce Reverb Kurangi Gema + Dynamics Dinamika + EQ EQ + Enhance Speech Tingkatkan Ucapan + Low Tone Nada Rendah + High Tone Nada Tinggi + Mix Campuran + Reverb Reverb + Stereo Width Lebar Stereo + Remixing music to a target duration is not available yet. Trim the clip and add a crossfade instead. Remix musik ke durasi target belum tersedia. Sebagai gantinya, pangkas klip dan tambahkan crossfade. + Pan Pan + Auto-Match Cocokkan Otomatis + Not matched yet Belum dicocokkan + Matches each of the {n} clips on its own. Mencocokkan masing-masing dari {n} klip secara terpisah. + Duck against: Duck terhadap: + Untagged Tanpa tag + Sensitivity Sensitivitas + Reduce By Kurangi Sebesar + Fades Fade + Generate Keyframes Buat Bingkai Kunci + Clip Volume Volume Klip + (no clip selected) (tidak ada klip yang dipilih) + Master · {name} Master · {name} + Add Lumetri Color to clip Tambahkan Lumetri Color ke klip + Input LUT LUT Input + Color Warna + Temperature Suhu + Tint Rona + Saturation Saturasi + Exposure Eksposur + Contrast Kontras + Highlights Sorotan + Shadows Bayangan + Whites Putih + Blacks Hitam + HDR HDR + HDR White Putih HDR + HDR Specular Spekular HDR + Look Look + Look LUT LUT Look + Intensity Intensitas + Adjustments Penyesuaian + Faded Film Film Pudar + Sharpen Pertajam + Vibrance Vibrance + RGB Curves Kurva RGB + HDR Range Rentang HDR + Hue Saturation Curves Kurva Hue Saturasi + Hue vs Sat Hue vs Sat + Hue vs Hue Hue vs Hue + Hue vs Luma Hue vs Luma + Luma vs Sat Luma vs Sat + Sat vs Sat Sat vs Sat + Midtones Nada Tengah + Key Key + Hue Hue + Hue Range Rentang Hue + Saturation Min Saturasi Min + Luma Min Luma Min + Luma Max Luma Maks + Soften Lembutkan + Show Mask Tampilkan Masker + Off Mati + Color/Gray Warna/Abu-abu + Color/Black Warna/Hitam + White/Black Putih/Hitam + Refine Perhalus + Denoise Hilangkan Derau + Blur Buram + Correction Koreksi + Hue Shift Geser Hue + Midpoint Titik Tengah + Roundness Kebulatan + Feather Feather + LUT LUT + Color Match Pencocokan Warna + Reference Referensi + Face Detection (protect skin tones) Deteksi Wajah (lindungi warna kulit) + Apply Match Terapkan Pencocokan + Create ellipse mask Buat masker elips + Create 4-point polygon mask Buat masker poligon 4 titik + Free draw bezier Gambar bezier bebas + Click in the Program monitor to place mask points (drag for curves); click the first point or press Enter to close Klik di monitor Program untuk menempatkan titik masker (seret untuk kurva); klik titik pertama atau tekan Enter untuk menutup + Delete Mask Hapus Masker + Inverted Terbalik + Drag: move · Alt-click: smooth/corner · Cmd-click: delete Seret: pindahkan · Alt-klik: halus/sudut · Cmd-klik: hapus + Track selected mask backward Lacak masker terpilih mundur + Track selected mask backward 1 frame Lacak masker terpilih mundur 1 bingkai + Track selected mask forward 1 frame Lacak masker terpilih maju 1 bingkai + Track selected mask forward Lacak masker terpilih maju + Stop tracking Hentikan pelacakan + Tracking method: {method} Metode pelacakan: {method} + Composite Komposit + Comparison Perbandingan + Video and Waveform Video dan Bentuk Gelombang + System Default Bawaan Sistem + Default location Lokasi bawaan + Font family Keluarga font + Label Colors Warna Label + Decoded frames held for the monitors and thumbnails: {used} of {budget}. Bingkai terdekode yang disimpan untuk monitor dan gambar mini: {used} dari {budget}. + FilmCraft keeps the rest of the RAM free for other applications. FilmCraft membiarkan sisa RAM bebas untuk aplikasi lain. + not available in this session tidak tersedia di sesi ini + Currently: {loc} — {files} file(s), {size} Saat ini: {loc} — {files} berkas, {size} + ({ch} output channels) ({ch} kanal output) + Programme left → device output {l}, right → device output {r}{dev}. Outputs beyond the device's channels fall back to 1 and 2. Program kiri → output perangkat {l}, kanan → output perangkat {r}{dev}. Output di luar kanal perangkat kembali ke 1 dan 2. + Auto-saves go to: {dir} Simpanan otomatis disimpan ke: {dir} + Recovery copy: {dir} Salinan pemulihan: {dir} + Last auto-save {a} · last recovery copy {j} Simpan otomatis terakhir {a} · salinan pemulihan terakhir {j} + Last auto-save {a} Simpan otomatis terakhir {a} + Last recovery copy {j} Salinan pemulihan terakhir {j} + Remove Media Cache Files Hapus Berkas Cache Media + Deleted {n} file(s), {size}. {n} berkas dihapus, {size}. + Reset… Atur Ulang… + Source · {name} Sumber · {name} + Reset Effect Atur Ulang Efek + Reset Parameter Atur Ulang Parameter + Save Preset… Simpan Preset… + Custom Setup Penyiapan Kustom + Edit… Edit… + Show graphs Tampilkan grafik + Toggle animation Alihkan animasi + Eyedropper (pick a color from the Program monitor) Pipet (ambil warna dari monitor Program) + Eyedropper: no picture to sample Pipet: tidak ada gambar untuk diambil sampelnya + Add/Remove Keyframe Tambah/Hapus Bingkai Kunci + Temporal Interpolation Interpolasi Temporal + Linear Linier + Bezier Bezier + Auto Bezier Bezier Otomatis + Continuous Bezier Bezier Kontinu + Ease In Ease In + Ease Out Ease Out + Select a clip to grade Pilih klip untuk di-grading + Select a clip to see its properties Pilih klip untuk melihat propertinya + Anchor point Titik jangkar + Rotation Rotasi + Level Level + Speed {n}% Kecepatan {n}% + Value Nilai + Velocity Kecepatan + Default Bawaan + Custom ({n} points) Kustom ({n} titik) + Select a graphic clip, or use the Type tool (T) on the Program monitor Pilih klip grafis, atau gunakan alat Teks (T) di monitor Program + Create new graphic Buat grafis baru + New Text Layer Lapisan Teks Baru + New Text Teks Baru + New Rectangle Persegi Panjang Baru + New Ellipse Elips Baru + Delete Layer Hapus Lapisan + Select a layer to edit its properties. Pilih lapisan untuk mengedit propertinya. + Align Left Ratakan Kiri + Align Center Horizontally Ratakan Tengah Secara Horizontal + Align Right Ratakan Kanan + Align Top Ratakan Atas + Align Center Vertically Ratakan Tengah Secara Vertikal + Align Bottom Ratakan Bawah + Font Font + Font Style Gaya Font + Font Size Ukuran Font + Alignment Perataan + Left Align Text Rata Kiri Teks + Center Align Text Rata Tengah Teks + Right Align Text Rata Kanan Teks + Justify Rata Kiri-Kanan + Tracking Tracking + Leading Spasi Baris + Baseline Shift Geser Garis Dasar + Text Box Width Lebar Kotak Teks + Style Gaya + Faux Bold Tebal Semu + Faux Italic Miring Semu + All Caps Semua Kapital + Small Caps Kapital Kecil + Underline Garis Bawah + OpenType OpenType + Kerning Kerning + Ligatures Ligatur + Shape Bentuk + Size Ukuran + Corner Radius Radius Sudut + Polygon Sides Sisi Poligon + Fill Isian + Stroke Garis Tepi + Stroke 2 Garis Tepi 2 + Background Latar Belakang + Opacity Opasitas + Size Ukuran + Corner Radius Radius Sudut + Shadow Bayangan + Angle Sudut + Distance Jarak + Blur Buram + Search templates Cari templat + All Categories Semua Kategori + Install… Pasang… + Install Motion Graphics Template (.fcgt) Pasang Templat Grafis Gerak (.fcgt) + No templates match. Tidak ada templat yang cocok. + {category} · My Template {category} · Templat Saya + Apply Terapkan + Remove Hapus + {n} editable properties {n} properti yang dapat diedit + Template: {name} Templat: {name} + Intro Duration Durasi Intro + Outro Duration Durasi Outro + Roll Gulung + Start Off Screen Mulai di Luar Layar + End Off Screen Berakhir di Luar Layar + Preroll Preroll + Postroll Postroll + Video Frame Bingkai Video + Pin To Sematkan ke + Pinned Edges Tepi Tersemat + FilmCraft Graphics Template Templat Grafis FilmCraft + {n} Font Font {n} + {n} Size Ukuran {n} + {n} Color Warna {n} + {n} Position Posisi {n} + Show {n} Tampilkan {n} + My Templates Templat Saya + Export As Motion Graphics Template Ekspor sebagai Templat Grafis Gerak + Category: Kategori: + Editable properties: Properti yang dapat diedit: + Saved as a FilmCraft graphics template (.fcgt) in your templates folder. Disimpan sebagai templat grafis FilmCraft (.fcgt) di folder templat Anda. + Exported graphics template to {path} Templat grafis diekspor ke {path} + Replace Fonts in Projects Ganti Font di Proyek + Fonts used in the project: Font yang dipakai di proyek: + {f} (missing) {f} (hilang) + Replace: Ganti: + Replaced {n} font uses {n} penggunaan font diganti + Show/Hide Effects and Sends Tampilkan/Sembunyikan Efek dan Send + Audio Track Mixer menu Menu Mixer Trek Audio + Show/Hide Tracks… Tampilkan/Sembunyikan Trek… + Meter Input(s) Only Hanya Meter Input + Show/Hide Tracks Tampilkan/Sembunyikan Trek + Show All Tampilkan Semua + Post-Fader Pasca-Fader + Remove Effect Hapus Efek + Fill the slots above first Isi slot di atas terlebih dahulu + pre pra + Pre-Fader Pra-Fader + Mute Send Bisukan Send + Remove Send Hapus Send + New Submix Submix Baru + Pan / balance (drag; double-click: centre) Pan / keseimbangan (seret; klik ganda: tengah) + Solo safe Aman dari solo + Enable track for recording Aktifkan trek untuk perekaman + Volume (drag; double-click: 0 dB) Volume (seret; klik ganda: 0 dB) + 5.1 panner (drag the puck; double-click: front centre) Panner 5.1 (seret penanda; klik ganda: tengah depan) + Center Tengah + Go to In Point Ke Titik Masuk + Go to Out Point Ke Titik Keluar + Play In to Out Putar dari Masuk ke Keluar + Record (voice-over on the record-enabled track) Rekam (sulih suara di trek yang diaktifkan untuk perekaman) + Write keyframes Tulis bingkai kunci + Something went wrong while drawing the window. Your project is still open; save it to be safe. Terjadi kesalahan saat menggambar jendela. Proyek Anda masih terbuka; simpan agar aman. + Continue Lanjutkan + Go Back Kembali + Go Forward Maju + Up One Level Naik Satu Tingkat + File Types Displayed Jenis Berkas yang Ditampilkan + List View Tampilan Daftar + Thumbnail View Tampilan Gambar Mini + Ingest Ingest + Open Ingest Settings Buka Pengaturan Ingest + Import as Image Sequence Impor sebagai Urutan Gambar + {sel} of {total} items selected {sel} dari {total} item dipilih + {total} items {total} item + Cannot open {dir}: {e} Tidak dapat membuka {dir}: {e} + Favorites Favorit + Local Drives Drive Lokal + Network Jaringan + Recent Directories Direktori Terbaru + (none) (tidak ada) + Remove from Favorites Hapus dari Favorit + Add to Favorites Tambah ke Favorit + Clear Recent Directories Hapus Direktori Terbaru + Open In Source Monitor Buka di Monitor Sumber + Reveal in Finder Tampilkan di Finder + Edit Columns… Edit Kolom… + Hover Scrub Scrub saat Melayang + New Media Browser Panel Panel Peramban Media Baru + Create Folder For Imported Projects Buat Folder untuk Proyek yang Diimpor + Allow Duplicate Media During Project Import Izinkan Media Duplikat Saat Impor Proyek + Refresh Segarkan + Edit Columns Edit Kolom + Guides Garis Bantu + (no audio) (tanpa audio) + Generating waveform… Membuat bentuk gelombang… + Reference {tc} Referensi {tc} + Set Atur + Set the reference to the playhead Atur referensi ke kepala putar + Reference: next frame Referensi: bingkai berikutnya + Reference: previous frame Referensi: bingkai sebelumnya + RGBA Channels Kanal RGBA + Add Guide Tambah Garis Bantu + Save Guides as Template Simpan Garis Bantu sebagai Templat + Manage Guides Kelola Garis Bantu + Orientation: Orientasi: + Vertical Vertikal + Horizontal Horizontal + Position: Posisi: + No saved guide templates. Tidak ada templat garis bantu tersimpan. + Project: {name} Proyek: {name} + Bin: {name} Bin: {name} + Up one level Naik satu tingkat + Video Usage: {vu} Audio Usage: {au} Penggunaan Video: {vu} Penggunaan Audio: {au} + Icon View Tampilan Ikon + Freeform View Tampilan Bebas + Sort Icons Urutkan Ikon + Clear (Delete) Hapus (Delete) + New Item Item Baru + New Bin Bin Baru + Find Cari + Automate to Sequence Otomatiskan ke Sekuens + Select items to clear Pilih item yang akan dihapus + User Order Urutan Pengguna + Descending Menurun + Refresh Project Segarkan Proyek + Reveal Project in Finder… Tampilkan Proyek di Finder… + New Bin From Selection Bin Baru dari Pilihan + New Search Bin Bin Pencarian Baru + New Project Shortcut Pintasan Proyek Baru + List Daftar + Icon Ikon + Freeform Bebas + Preview Area Area Pratinjau + Thumbnails Gambar Mini + Thumbnails show effects applied Gambar mini menampilkan efek yang diterapkan + Thumbnail controls for all pointing devices Kontrol gambar mini untuk semua perangkat penunjuk + Refresh Sort Order Segarkan Urutan + Metadata Display… Tampilan Metadata… + Save Current View Preset Simpan Preset Tampilan Saat Ini + Restore View Preset Pulihkan Preset Tampilan + Project View Preset {n} Preset Tampilan Proyek {n} + Freeform View Options… Opsi Tampilan Bebas… + Media missing Media hilang + Unreadable Tidak terbaca + Open in Source Monitor Buka di Monitor Sumber + Open in Timeline Buka di Linimasa + New Sequence From Clip Sekuens Baru dari Klip + Set Poster Frame Atur Bingkai Poster + Proxy Proksi + Open in Place Buka di Tempat + Open in New Tab Buka di Tab Baru + Open in New Window Buka di Jendela Baru + {n} items {n} item + Stack Tumpuk + Unstack Lepas Tumpukan + Clip Size Ukuran Klip + Larger Lebih Besar + Smaller Lebih Kecil + Align to Grid Ratakan ke Kisi + Reset to Grid Atur Ulang ke Kisi + Save Arrangement… Simpan Susunan… + Restore Arrangement Pulihkan Susunan + Delete Arrangement Hapus Susunan + Drop media here to create sequence. Letakkan media di sini untuk membuat sekuens. + Double-click a sequence in the Project panel to open it here. Klik ganda sekuens di panel Proyek untuk membukanya di sini. + Open Demo Project Buka Proyek Demo + Insert and overwrite sequences as nests or individual clips Sisipkan dan timpa sekuens sebagai sarang atau klip individual + Snap in Timeline (S) Snap di Linimasa (S) + Caption track options Opsi trek takarir + Timeline Display Settings Pengaturan Tampilan Linimasa + Show Video Thumbnails Tampilkan Gambar Mini Video + Head Awal + Head and Tail Awal dan Akhir + Continuous Kontinu + Show Audio Waveform Tampilkan Bentuk Gelombang Audio + Expand All Tracks Bentangkan Semua Trek + Minimize All Tracks Minimalkan Semua Trek + Unrendered: should play back in real time Belum dirender: seharusnya dapat diputar secara real time + Unrendered: render to play back in real time Belum dirender: render agar dapat diputar secara real time + Rendered preview Pratinjau yang dirender + ✓ Enable ✓ Aktifkan + Unlink Lepas Tautan + (Default) (Bawaan) + (RGB) Gamma (RGB) Gamma + (RGB) Input Black Level (RGB) Level Hitam Input + (RGB) Input White Level (RGB) Level Putih Input + (RGB) Output Black Level (RGB) Level Hitam Output + (RGB) Output White Level (RGB) Level Putih Output + + Cmd + Cmd + + Opt + Opt + 10 Bit Black Point Titik Hitam 10 Bit + 10 Bit White Point Titik Putih 10 Bit + 105mm Prime Lensa Prime 105mm + 29.97 fps Drop-Frame 29,97 fps Drop-Frame + 29.97 fps Non Drop-Frame 29,97 fps Non Drop-Frame + 35mm Prime Lensa Prime 35mm + 3D Roll Gulung 3D + 3D Rotate Rotasi 3D + 3D Spin Putaran 3D + 3D Spinback Putaran Balik 3D + 4-Color Gradient Gradien 4 Warna + 5.1 Media Media 5.1 + 5.1 Mixdown Type Jenis Mixdown 5.1 + 50-300mm Zoom Lensa Zoom 50-300mm + 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 Kontras warna yang aksesibel + Action-Safe Margin Margin Aman Aksi + Active Monitor Monitor Aktif + Add Tambah + Add tracks automatically when editing source clips onto the Timeline Tambah trek secara otomatis saat mengedit klip sumber ke Linimasa + Place files dropped onto the Timeline directly on the Timeline Tempatkan berkas yang diletakkan ke Linimasa langsung di Linimasa + 1 file 1 berkas + {n} files {n} berkas + Additional Scale Skala Tambahan + Additive Dissolve Larut Aditif + Additive Dissolve (Legacy) Larut Aditif (Lama) + Adjust Sesuaikan + Advanced Lanjutan + Aggressive Agresif + Align and Transform Ratakan dan Transformasi + All lines Semua baris + Allow Selection tool to choose Roll and Ripple trims without modifier key Izinkan alat Seleksi memilih pemangkasan Roll dan Ripple tanpa tombol pengubah + Allow current tool to change trim type of previously selected edit point Izinkan alat saat ini mengubah jenis pemangkasan titik edit yang dipilih sebelumnya + Allow duplicate media during project import Izinkan media duplikat saat impor proyek + Alpha Adjust Penyesuaian Alfa + Alpha Blurriness Keburaman Alfa + Alpha Channel Kanal Alfa + Alpha Glow Pendar Alfa + Always import embedded CEA-708 closed captions with wide aspect. Selalu impor takarir tertutup CEA-708 tersemat dengan aspek lebar. + Always override audio tags Selalu timpa tag audio + Ambience Ambiens + Ambience Intensity Intensitas Ambiens + Ambience Light Color Warna Cahaya Ambiens + Amount of Noise Jumlah Derau + Amount to Decolor Jumlah Penghilangan Warna + Amount to Tint Jumlah Pewarnaan + Amplifier Penguat + Amplify Perkuat + Amplitude Amplitudo + Amplitude and Compression Amplitudo dan Kompresi + Analog Analog + Analog Delay Delay Analog + Analyze all imported media to search for visuals or audio Analisis semua media yang diimpor untuk pencarian visual atau audio + Anamorphic Anamorfik + Anchor Jangkar + Anchor Point Titik Jangkar + Angle Sudut + Animation Animasi + Anti-aliasing Quality Kualitas Anti-aliasing + Anti-flicker Filter Filter Anti-kedip + Antialiasing for Best Quality Antialiasing untuk Kualitas Terbaik + Asymmetry Asimetri + At Startup Saat Memulai + At playback end, return to beginning when restarting playback Di akhir pemutaran, kembali ke awal saat memulai ulang pemutaran + Attempt to force hardware to document sample rate Coba paksa perangkat keras ke laju sampel dokumen + Audio Codec Codec Audio + Audio Duration Durasi Audio + Audio Effects Efek Audio + Audio Hardware Perangkat Keras Audio + Audio In Point Titik Masuk Audio + Audio Info Info Audio + Audio Out Point Titik Keluar Audio + Audio Samples Sampel Audio + Audio Time Units Satuan Waktu Audio + Audio Transition Default Duration Durasi Bawaan Transisi Audio + Audio Transitions Transisi Audio + Audio Usage Penggunaan Audio + Auto Otomatis + Auto Align Perataan Otomatis + Auto Reframe Bingkai Ulang Otomatis + Auto Save Simpan Otomatis + Auto Save also saves the current project(s) Simpan Otomatis juga menyimpan proyek saat ini + Auto-tag audio types in the timeline Tandai otomatis jenis audio di linimasa + Auto-transcribe all imported clips Transkripsikan otomatis semua klip yang diimpor + Auto-transcribe only clips in sequences Transkripsikan otomatis hanya klip di sekuens + Automatch Time Waktu Pencocokan Otomatis + Automatic Click Remover Penghapus Klik Otomatis + Automatically Hide Dependent Clips Sembunyikan Otomatis Klip Dependen + Automatically Save Every Simpan Otomatis Setiap + Automatically delete cache files older than Hapus otomatis berkas cache yang lebih lama dari + Automatically delete oldest cache files when cache exceeds Hapus otomatis berkas cache terlama saat cache melebihi + Automatically refresh growing files Segarkan otomatis berkas yang sedang bertambah + Automatically resume playback for growing files in Source Monitor Lanjutkan otomatis pemutaran berkas yang sedang bertambah di Monitor Sumber + Automatically save projects Simpan proyek secara otomatis + Automatically transcribe clips Transkripsikan klip secara otomatis + Automation Keyframe Optimization Optimasi Bingkai Kunci Otomasi + Background Color Warna Latar Belakang + Background Corner Radius Radius Sudut Latar Belakang + Background Opacity Opasitas Latar Belakang + Background Size Ukuran Latar Belakang + Background fill Isian latar belakang + Balance Keseimbangan + Balanced Voice Suara Seimbang + Band 1 Pita 1 + Band 2 Pita 2 + Band 3 Pita 3 + Band 4 Pita 4 + Band 5 Pita 5 + Band Pass Band Pass + Band Slide Geser Pita + Band Stop Band Stop + Bandpass Bandpass + Barn Doors Pintu Gudang + Basic Dasar + Basic 3D 3D Dasar + Basic Correction Koreksi Dasar + Bass Bass + Behind Di Belakang + Bessel Bessel + Bevel Alpha Bevel Alfa + Bevel join Sambungan bevel + Binauralizer - Ambisonics Binauralizer - Ambisonik + Bins Bin + Black Hitam + Black & White Hitam & Putih + Black Input Level Level Input Hitam + Black and white Hitam putih + Black and white with crushed blacks and strong contrast Hitam putih dengan hitam pekat dan kontras kuat + Bleach Bypass Bleach Bypass + Bleach-bypass style: desaturated with hard contrast Gaya bleach-bypass: saturasi rendah dengan kontras keras + Blend Campur + Blend Mode Mode Campur + Blend With Original Campur dengan Asli + Blending Mode Mode Pencampuran + Block Dissolve Larut Blok + Block Height Tinggi Blok + Block Motion Gerak Blok + Block Size Ukuran Blok + Block Width Lebar Blok + Blocks Blok + Blue Blurriness Keburaman Biru + Blue Curve Kurva Biru + Blue Offset Offset Biru + Blue Power Pangkat Biru + Blue Slope Kemiringan Biru + Blue-Blue Biru-Biru + Blue-Const Biru-Konst + Blue-Green Biru-Hijau + Blue-Red Biru-Merah + Blur & Sharpen Buram & Pertajam + Blur Amount Jumlah Buram + Blur Angle Sudut Buram + Blur Dimensions Dimensi Buram + Blur Dissolve Larut Buram + Blur Layer Lapisan Buram + Blur Length Panjang Buram + Blurriness Keburaman + Bokeh Blur Buram Bokeh + Boost Penguatan + Border Batas + Border Color Warna Batas + Border Width Lebar Batas + Borders Batas + Bottom Center Tengah Bawah + Bottom Left Kiri Bawah + Bottom Right Kanan Bawah + Bounce Pantul + Branching Percabangan + Bright Cerah + Bright Air Udara Cerah + Bright Studio Studio Cerah + Brightness & Contrast Kecerahan & Kontras + British Stack Tumpukan Inggris + Brush Hardness Kekerasan Kuas + Brush Opacity Opasitas Kuas + Brush Position Posisi Kuas + Brush Size Ukuran Kuas + Brush Spacing (secs) Jarak Kuas (dtk) + Brush Strokes Sapuan Kuas + Bubbles Gelembung + Bulge Gembung + Bulge Smoother Penghalus Gembung + Bump Height Tinggi Tonjolan + Burn Alpha Bakar Alfa + Burn Chroma Bakar Kroma + Burn Color Warna Bakar + Butt cap Ujung rata + Butterworth Butterworth + CEA-608 CEA-608 + CEA-708 CEA-708 + Cache analysis results for re-use Simpan hasil analisis di cache untuk dipakai ulang + Camera Angle Sudut Kamera + Camera Blur Buram Kamera + Camera Shake Guncangan Kamera + Capitalisation Kapitalisasi + Cathedral Katedral + Cell Pattern Pola Sel + Center Channel Pan Pan Kanal Tengah + Center Gradient Gradien Tengah + Center Input Input Tengah + Center Out Tengah ke Luar + Center Point (Latitude) Titik Tengah (Lintang) + Center Point (Longitude) Titik Tengah (Bujur) + Center Split Belah Tengah + Center of Sphere Pusat Bola + Cents Sen + Change to Color Ubah ke Warna + Changes are copied in the background a moment after each edit. If FilmCraft quits unexpectedly, they are offered the next time it starts. Perubahan disalin di latar belakang sesaat setelah setiap edit. Jika FilmCraft keluar secara tak terduga, perubahan itu ditawarkan saat FilmCraft dimulai berikutnya. + Channel Kanal + Channel Blur Buram Kanal + Channel Mix Campuran Kanal + Channel Mixer Mixer Kanal + Chaos Kekacauan + Chebyshev Chebyshev + Checker Wipe Sapuan Kotak-kotak + Checkerboard Papan Catur + Chinese Tionghoa + Choke Choke + Chorus Chorus + Chorus/Flanger Chorus/Flanger + Chroma Kroma + Chroma Leak Kebocoran Kroma + Chromatic Aberration (Blue) Aberasi Kromatik (Biru) + Chromatic Aberration (Green) Aberasi Kromatik (Hijau) + Chromatic Aberration (Red) Aberasi Kromatik (Merah) + Cineon Converter Konverter Cineon + Circle Lingkaran + Clarity Kejernihan + Clean Combo Kombo Bersih + Clean Up Noisy Dialogue Bersihkan Dialog Berderau + Clear Muddiness Hilangkan Kekeruhan + Client Klien + Clip Klip + Clip Level Level Klip + Clipping Clipping + Clock Wipe Sapuan Jam + Clock Wipe (Legacy) Sapuan Jam (Lama) + Clockwise Searah Jarum Jam + Clone Klon + Closed Captions Takarir Tertutup + Color & Alpha Warna & Alfa + Color 1 Warna 1 + Color 2 Warna 2 + Color 3 Warna 3 + Color 4 Warna 4 + Color Balance Keseimbangan Warna + Color Burn Bakar Warna + Color Channel Kanal Warna + Color Correction Koreksi Warna + Color Distortion Distorsi Warna + Color Dodge Dodge Warna + Color Emboss Emboss Warna + Color Key Key Warna + Color Pass Lolos Warna + Color Replace Ganti Warna + Color Space Ruang Warna + Color Theme Tema Warna + Color To Leave Warna yang Dipertahankan + Color Tolerance Toleransi Warna + Color Wheels & Match Roda Warna & Pencocokan + Colorize Warnai + Columns Kolom + Comment Komentar + Complexity Kompleksitas + Composite In Back Komposit di Belakang + Composite In Front Komposit di Depan + Composite Using Komposit Menggunakan + Composite on Original Komposit pada Asli + Compound Blur Buram Majemuk + Compression Before Clipping Kompresi Sebelum Clipping + Constant Gain Gain Konstan + Constant Power Daya Konstan + Conversion Type Jenis Konversi + Convolution Reverb Reverb Konvolusi + Cool Blue Biru Sejuk + Cool, desaturated and punchy Sejuk, saturasi rendah, dan tegas + Core Width Lebar Inti + Corner Sudut + Corner Pin Pin Sudut + Count Jumlah + Counter-clockwise Berlawanan Arah Jarum Jam + Crash Recovery Pemulihan Crash + Crawl Left Merayap ke Kiri + Crawl Right Merayap ke Kanan + Create folder for imported projects Buat folder untuk proyek yang diimpor + Creative Kreatif + Crop Potong Tepi + Crop Less <-> Smooth More Potong Lebih Sedikit <-> Lebih Halus + Cross Displacement Perpindahan Silang + Cross Dissolve Larut Silang + Cross Dissolve (Legacy) Larut Silang (Lama) + Cross Zoom Zoom Silang + Cross Zoom (Legacy) Zoom Silang (Lama) + Crossfade Crossfade + Crossovers Crossover + Crystallize Kristalisasi + Crystals Kristal + Cube Spin Putaran Kubus + Curl Radius Radius Gulungan + Current Time Waktu Saat Ini + Cursor Kursor + Curvature Kelengkungan + Curve Kurva + Curves Kurva + Custom Kustom + Custom Color Warna Kustom + Cutoff Batas Potong + Cyan-teal shadows and warm highlights with a little more contrast Bayangan sian-toska dan sorotan hangat dengan sedikit lebih banyak kontras + Cycle (in Revolutions) Siklus (dalam Putaran) + Cycle Evolution Evolusi Siklus + Damping Peredaman + Damping HF Peredaman HF + Damping LF Peredaman LF + Dark Gelap + Darken Gelapkan + Darker Color Warna Lebih Gelap + Date Created Tanggal Dibuat + Date Modified Tanggal Diubah + Day for night: darker, cooler and less saturated Siang jadi malam: lebih gelap, lebih sejuk, dan saturasi lebih rendah + DeEsser DeEsser + DeHummer DeHummer + DeNoise DeNoise + DeReverb DeReverb + Decay Peluruhan + Decay Time (RT60) Waktu Peluruhan (RT60) + Default Audio Tracks Trek Audio Bawaan + Default Emoji font Font Emoji bawaan + Default Media Scaling Penskalaan Media Bawaan + Default Output Output Bawaan + Default closed caption font Font takarir tertutup bawaan + Default language Bahasa bawaan + Default paragraph direction Arah paragraf bawaan + Default subtitle and text font Font subtitel dan teks bawaan + Delay Delay + Delay 1 Delay 1 + Delay 2 Delay 2 + Delay 3 Delay 3 + Delay 4 Delay 4 + Delay and Echo Delay dan Gema + Delete files older than Hapus berkas yang lebih lama dari + Delete oldest files when the cache exceeds Hapus berkas terlama saat cache melebihi + Delete… Hapus… + Depth Kedalaman + Desaturate Hilangkan Saturasi + Description Deskripsi + Detail Amplitude Amplitudo Detail + Detail Level Level Detail + Detailed Analysis Analisis Terperinci + Device Class Kelas Perangkat + Dialogue Dialog + Diamond Berlian + Difference Selisih + Dimensions Dimensi + Dimmed Diredupkan + Dip to Black Celup ke Hitam + Dip to Black (Legacy) Celup ke Hitam (Lama) + Dip to Color Celup ke Warna + Dip to White Celup ke Putih + Dip to White (Legacy) Celup ke Putih (Lama) + Direction Arah + Directional Terarah + Directional Blur Buram Terarah + Directional Blur (Legacy) Buram Terarah (Lama) + Disable video output when in the background Nonaktifkan output video saat di latar belakang + Disperse Sebarkan + Displacement Perpindahan + Display Color Management Manajemen Warna Layar + Display Color Settings Pengaturan Warna Layar + Display out of sync indicators for unlinked clips Tampilkan indikator tidak sinkron untuk klip yang tidak tertaut + Dissolve Larut + Dissolve Blocks Blok Larut + Distance Jarak + Distance to Image Jarak ke Gambar + Distort Distorsi + Distortion Distorsi + Distortion Complexity Kompleksitas Distorsi + Distortion Rate Laju Distorsi + Distortion Type Jenis Distorsi + Divide Bagi + Do not delete cache files automatically Jangan hapus berkas cache secara otomatis + Don't label speakers Jangan beri label pembicara + Double-click Klik ganda + Draft decoding at reduced playback resolution (H.264: faster, some frames less filtered) Dekode draf pada resolusi pemutaran rendah (H.264: lebih cepat, beberapa bingkai kurang tersaring) + Drive Drive + Drop Shadow Bayangan Jatuh + Dry Kering + Dry Out Output Kering + Dry Up a Roomy Voice Keringkan Suara yang Bergema + Duck Deep Under Dialogue Duck Dalam di Bawah Dialog + Duck Under Dialogue Duck di Bawah Dialog + Duck Under Voice and Effects Duck di Bawah Suara dan Efek + Ducking Ducking + Dutch Belanda + Dynamic Link Dynamic Link + Dynamics Processing Pemrosesan Dinamika + Early Reflections Pantulan Awal + Earthquake Gempa Bumi + Ease In and Out Ease In dan Out + Easing Easing + Echo Gema + Echo Glow Pendar Gema + Echo Operator Operator Gema + Echo Time (seconds) Waktu Gema (detik) + Echoes Gema + Edge Color Warna Tepi + Edge Feather Feather Tepi + Edge Glow Pendar Tepi + Edge Sharpness Ketajaman Tepi + Edge Softness Kelembutan Tepi + Edge Thickness Ketebalan Tepi + Edge Thin Penipisan Tepi + Edge Threshold Ambang Tepi + Edge Type Jenis Tepi + Edge Width Lebar Tepi + Elliptical Eliptis + Emboss Emboss + Enable HSL Secondary Aktifkan HSL Sekunder + Enable Mercury Transmit Aktifkan Mercury Transmit + Enable ProRes hardware accelerated encoding, if available Aktifkan enkode ProRes dengan akselerasi perangkat keras, jika tersedia + Enable clip and XMP metadata linking Aktifkan penautan metadata klip dan XMP + Enable developer mode Aktifkan mode pengembang + Enable hardware accelerated decoding (requires restart) Aktifkan dekode dengan akselerasi perangkat keras (perlu mulai ulang) + Enable language auto-detection Aktifkan deteksi bahasa otomatis + Enable proxies Aktifkan proksi + End Color Warna Akhir + End Point Titik Akhir + End of Ramp Akhir Gradasi + Error Galat + Evolution Evolusi + Evolution Options Opsi Evolusi + Exciter Amount Jumlah Exciter + Exciter Mode Mode Exciter + Exclusion Eksklusi + Exponential Fade Fade Eksponensial + Extended Dynamic Range Monitoring Pemantauan Rentang Dinamis Diperluas + FFT Filter Filter FFT + Fade Fade + Fade Out Fade Out + Faded Matte Matte Pudar + Falloff Penurunan + Far Away Jauh + Fast Cepat + Faster Motion Gerak Lebih Cepat + Feedback Umpan Balik + Feedback 1 Umpan Balik 1 + Feedback 2 Umpan Balik 2 + Feedback 3 Umpan Balik 3 + Feedback 4 Umpan Balik 4 + Feet + Frames 16mm Kaki + Bingkai 16mm + Feet + Frames 35mm Kaki + Bingkai 35mm + Female Perempuan + File Size Ukuran Berkas + Fill Background Isi Latar Belakang + Fill Color Warna Isian + Fill Left with Right Isi Kiri dengan Kanan + Fill Point Titik Isian + Fill Right with Left Isi Kanan dengan Kiri + Fill Selector Pemilih Isian + Film Dissolve Larut Film + Film Dissolve (Legacy) Larut Film (Lama) + Film Roll Gulungan Film + Filter Filter + Filter Frequency Frekuensi Filter + Filter Resonance Resonansi Filter + Filter and EQ Filter dan EQ + Final Delay Delay Akhir + Find Edges Temukan Tepi + Flanger Flanger + Flare Suar + Flare Brightness Kecerahan Suar + Flare Center Pusat Suar + Flare Color Warna Suar + Flash Kilat + Flicker Kedip + Flickers Kedipan + Flip Motion Gerak Balik + Flip Over Balik + Float Melayang + Focus Fokus + Focus Blur Buram Fokus + Focus Point Titik Fokus + Focus Shape Bentuk Fokus + Focus Size Ukuran Fokus + Fold Motion Gerak Lipat + Foldback Foldback + For color management parameters, go to the Settings tab in the Lumetri Color panel. Untuk parameter manajemen warna, buka tab Pengaturan di panel Lumetri Color. + Format Format + Four Scopes (RGB, 8 bit) Empat Skop (RGB, 8 bit) + Four Scopes (YUV, float, no clamp) Empat Skop (YUV, float, tanpa pembatasan) + Fractal Influence Pengaruh Fraktal + Fractal Type Jenis Fraktal + Frame Bingkai + Frame Color Warna Bingkai + Frame Count Jumlah Bingkai + Frame Layout Tata Letak Bingkai + Frame Rate Laju Bingkai + Frame Width Lebar Bingkai + Frame cache for monitors and thumbnails Cache bingkai untuk monitor dan gambar mini + Frames Bingkai + Framing Pembingkaian + French Prancis + Frequency Frekuensi + From Dari + From East Dari Timur + From North Dari Utara + From South Dari Selatan + From West Dari Barat + From the Left Dari Kiri + From the Right Dari Kanan + Front + LFE Depan + LFE + Front + Rear Depan + Belakang + Front + Rear + LFE Depan + Belakang + LFE + Front Only Hanya Depan + Fuzz Fuzz + GB GB + Gamma Gamma + Gamma Correction Koreksi Gamma + Gamut Warning Peringatan Gamut + Gamut Warning Color Warna Peringatan Gamut + Gap Celah + Gap Color Warna Celah + Gaussian Blur Buram Gaussian + Gaussian Blur (Legacy) Buram Gaussian (Lama) + General Umum + Generate Buat + Generate Timecode Buat Kode Waktu + Generate waveforms and conform audio using multithreading Buat bentuk gelombang dan sesuaikan audio menggunakan multithreading + Generate waveforms automatically during import Buat bentuk gelombang secara otomatis saat impor + German Jerman + Ghosts Bayangan Hantu + Glass Kaca + Glint Kilau + Glitch Glitch + Glow Pendar + Glow Brightness Kecerahan Pendar + Glow Color Warna Pendar + Glow Only Hanya Pendar + Glow Radius Radius Pendar + Glow Saturation Saturasi Pendar + Glow Width Lebar Pendar + Golden Hour Golden Hour + Good Baik + Gradient Gradien + Gradient End Akhir Gradien + Gradient Layer Lapisan Gradien + Gradient Placement Penempatan Gradien + Gradient Scatter Sebaran Gradien + Gradient Shape Bentuk Gradien + Gradient Start Awal Gradien + Gradient Wipe Sapuan Gradien + Gradient Wipe (Legacy) Sapuan Gradien (Lama) + Grain Size Ukuran Butiran + Graphic Equalizer (10 Bands) Equalizer Grafis (10 Pita) + Graphic Equalizer (20 Bands) Equalizer Grafis (20 Pita) + Graphic Equalizer (30 Bands) Equalizer Grafis (30 Pita) + Graphics Grafis + Green Blurriness Keburaman Hijau + Green Curve Kurva Hijau + Green Offset Offset Hijau + Green Power Pangkat Hijau + Green Slope Kemiringan Hijau + Green-Blue Hijau-Biru + Green-Const Hijau-Konst + Green-Green Hijau-Hijau + Green-Red Hijau-Merah + Grid Kisi + Grow Tumbuh + Growing Files Berkas yang Bertambah + Grunge Grunge + Grunge & Distort Grunge & Distorsi + GuitarSuite GuitarSuite + HDR Graphics White (Nits) Putih Grafis HDR (Nit) + HLS Vectorscope + Histogram Vektorskop HLS + Histogram + HSL Secondary HSL Sekunder + Hard Keras + Hard Clip Clip Keras + Hard Light Cahaya Keras + Hard Limiter Limiter Keras + Hard Mix Campuran Keras + Hardware decoding Dekode perangkat keras + 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. Dekode perangkat keras: Otomatis menggunakan dekoder video sistem (VideoToolbox di macOS, Media Foundation di Windows, VA-API atau NVDEC untuk H.264 dan HEVC di Linux) untuk stream yang didukungnya, dan dekoder FilmCraft sendiri untuk yang lainnya atau jika perangkat keras gagal. Media yang sudah terbuka tetap memakai dekodernya sampai dibuka ulang. + Head Size Ukuran Kepala + Heart Hati + Height Tinggi + Hexagon Segi Enam + Hide Back Face Sembunyikan Sisi Belakang + High Tinggi + High Cutoff Batas Atas + High Frequency Cut Potong Frekuensi Tinggi + High Frequency Damping Peredaman Frekuensi Tinggi + High Pass High Pass + High Shelf High Shelf + High Shelf Gain Gain High Shelf + High contrast with held highlights and deep shadows Kontras tinggi dengan sorotan terjaga dan bayangan pekat + Highlight Sorotan + Highlight Blue Balance Keseimbangan Biru Sorotan + Highlight Brightness Kecerahan Sorotan + Highlight Color Warna Sorotan + Highlight Green Balance Keseimbangan Hijau Sorotan + Highlight Red Balance Keseimbangan Merah Sorotan + Highlight Rolloff Rolloff Sorotan + Highlight Threshold Ambang Sorotan + Highlight Tint Rona Sorotan + Highlights Lightness Kecerahan Sorotan + Highpass Highpass + Hindi Hindi + Hindi digits Angka Hindi + Horizontal 16:9 Horizontal 16:9 + Horizontal Alignment Perataan Horizontal + Horizontal Blocks Blok Horizontal + Horizontal Decentering Pergeseran Pusat Horizontal + Horizontal Displacement Perpindahan Horizontal + Horizontal Flip Balik Horizontal + Horizontal Margin Margin Horizontal + Horizontal and Vertical Horizontal dan Vertikal + Hue Center Pusat Hue + Hue Tolerance Toleransi Hue + I/O Buffer Size Ukuran Buffer I/O + Ignore Alpha Abaikan Alfa + Image Control Kontrol Gambar + Immersive Video Video Imersif + Import numbered stills as image sequences Impor gambar diam bernomor sebagai urutan gambar + Impulse Impuls + In a Room Di Dalam Ruangan + In the Media Cache Di Cache Media + In the project Di proyek + In/Out Duration Durasi Masuk/Keluar + Include captions on import Sertakan takarir saat impor + Incoming Masuk + Indeterminate Media Timebase Basis Waktu Media Tak Tentu + Initial Delay Delay Awal + Inner Dalam + Inner Radius Radius Dalam + Input Boost Penguatan Input + Inset Sisipan + Inside Di Dalam + Inside Color Warna Dalam + Inter Inter + Interior Room Tone Nada Ruang Interior + Internal Black Point Titik Hitam Internal + Internal White Point Titik Putih Internal + Interpolation Interpolasi + Intersect Perpotongan + Inverse Falloff Penurunan Terbalik + Invert Balikkan + Invert Alpha Balikkan Alfa + Invert Blur Balikkan Buram + Invert Fill Balikkan Isian + Invert Gradient Balikkan Gradien + Invert Left Balikkan Kiri + Invert Right Balikkan Kanan + Iris Iris + Iris Box Iris Kotak + Iris Cross Iris Silang + Iris Diamond Iris Berlian + Iris Radius Radius Iris + Iris Rotation Rotasi Iris + Iris Round Iris Bulat + Iris Shape Bentuk Iris + Italian Italia + Japanese Jepang + JetBrains Mono JetBrains Mono + Join Through Edits Gabungkan Edit Tembus + Ripple Trim In Pangkas Ripple Masuk + Ripple Trim Out Pangkas Ripple Keluar + Roll Edit Edit Roll + Trim In Pangkas Masuk + Trim Out Pangkas Keluar + Kaleidoscope Kaleidoskop + Keep a recovery copy of unsaved changes Simpan salinan pemulihan untuk perubahan yang belum disimpan + Key Color Warna Key + Keying Keying + Korean Korea + Label Defaults Label Bawaan + Label speakers Beri label pembicara + Labels Label + Large Hall Aula Besar + Large Trim Offset Offset Pangkas Besar + Large Volume Adjustment Penyesuaian Volume Besar + Latch Latch + Layer Name Nama Lapisan + Layers Lapisan + Leave Color Pertahankan Warna + Left - Left Kiri - Kiri + Left - Right Kiri - Kanan + Left channel to device output Kanal kiri ke output perangkat + Left to Right Kiri ke Kanan + Left to right Kiri ke kanan + Legacy Lama + Length Panjang + Lens Blur Buram Lensa + Lens Distortion Distorsi Lensa + Lens Flare Suar Lensa + Lens Type Jenis Lensa + Level 1 Level 1 + Level 2 Level 2 + Level 3 Level 3 + Level 4 Level 4 + Levels Level + Lifted blacks, softer contrast, muted colour Hitam terangkat, kontras lebih lembut, warna redup + Light 1 Cahaya 1 + Light 2 Cahaya 2 + Light 3 Cahaya 3 + Light 4 Cahaya 4 + Light 5 Cahaya 5 + Light Angle Sudut Cahaya + Light Color Warna Cahaya + Light Intensity Intensitas Cahaya + Light Leak Kebocoran Cahaya + Light Leaks Kebocoran Cahaya + Light Sweep Sapuan Cahaya + Light Type Jenis Cahaya + Lighten Terangkan + Lighter Color Warna Lebih Terang + Lighting Effects Efek Pencahayaan + Lightning Petir + Lights & Blurs Cahaya & Buram + Lights & Glows Cahaya & Pendar + Line Thickness Ketebalan Garis + Linear Burn Bakar Linier + Linear Dodge (Add) Dodge Linier (Tambah) + Linear Light Cahaya Linier + Linear Ramp Gradasi Linier + Linear Wipe Sapuan Linier + Linear Wipe (Legacy) Sapuan Linier (Lama) + Linear keyframe thinning Penipisan bingkai kunci linier + Linear to Log Linier ke Log + Liquid Distortion Distorsi Cair + Local Projects Proyek Lokal + Location Lokasi + Log Note Catatan Log + Log to Linear Log ke Linier + Log to Log Log ke Log + Logo Cutout Potongan Logo + Long Shadow Bayangan Panjang + Look-Ahead Time Waktu Lihat ke Depan + Loudness Kenyaringan + Loudness Maximizer Pemaksimal Kenyaringan + Loudness Meter Meter Kenyaringan + Louver Kisi-kisi + Low Rendah + Low Frequency Cut Potong Frekuensi Rendah + Low Pass Low Pass + Low Shelf Low Shelf + Low Shelf Gain Gain Low Shelf + Lower Left Kiri Bawah + Lower Right Kanan Bawah + Lowpass Lowpass + Luma Luma + Luma Curve Kurva Luma + Luma Fade Fade Luma + Luma Key Key Luma + Luma Source Sumber Luma + Luma and Chroma Luma dan Kroma + Lumetri Presets Preset Lumetri + Luminance Luminans + Luminance Threshold Ambang Luminans + Luminosity Luminositas + MB MB + Magenta Magenta + Magnify Pembesar + Magnify (Legacy) Pembesar (Lama) + Maintain pitch while shuttling Pertahankan nada saat shuttle + Major Radius Radius Mayor + Male Laki-laki + Mango Mangga + Map Black To Petakan Hitam ke + Map White To Petakan Putih ke + Maps full-range levels into 16–235 (video range) with a luma curve Memetakan level rentang penuh ke 16–235 (rentang video) dengan kurva luma + Mask Only Hanya Masker + Master Amplitude Amplitudo Master + Mastering Mastering + Match frame sets in point Cocokkan bingkai menetapkan titik masuk + Matte Matte + Matte Alpha Alfa Matte + Matte Cleanup Pembersihan Matte + Matte Generation Pembuatan Matte + Matte Luma Luma Matte + Maximum Maksimum + Maximum Amplitude Amplitudo Maksimum + Maximum Block Size Ukuran Blok Maksimum + Maximum Blur Buram Maksimum + Maximum Project Versions Versi Proyek Maksimum + Maximum Reduction Pengurangan Maksimum + Maximum Scale Skala Maksimum + Media Media + Media Analysis Analisis Media + Media Analysis & Transcription Analisis Media & Transkripsi + Media Cache Cache Media + Media Cache Database Basis Data Cache Media + Media Cache Files Berkas Cache Media + Media Cache Management Manajemen Cache Media + Media Duration Durasi Media + Media End Akhir Media + Media Time Waktu Media + Media Timecode Kode Waktu Media + Media Type Jenis Media + 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. Analisis media dan transkripsi akan disimpan di folder Cache Media. Analisis ulang tidak diperlukan jika media diimpor ke proyek lain di komputer ini. + Median Median + Medium Room Ruangan Sedang + Memory Memori + Metadata & Timecode Burn-in Burn-in Metadata & Kode Waktu + Method Metode + Metrics Kerning Kerning Metrik + Mid Tengah + Mid Point Titik Tengah + Midtone Blue Balance Keseimbangan Biru Nada Tengah + Midtone Green Balance Keseimbangan Hijau Nada Tengah + Midtone Red Balance Keseimbangan Merah Nada Tengah + Midtones Lightness Kecerahan Nada Tengah + Minimum Minimum + Minimum time Waktu minimum + Minimum time interval thinning Penipisan interval waktu minimum + Minor Radius Radius Minor + Mirror Cermin + Mirror Alternate Cermin Bergantian + Missing font replacement Pengganti font yang hilang + Miter join Sambungan miter + Miter limit Batas miter + Mixed Crystals Kristal Campuran + Mode Mode + Modern High Gain Gain Tinggi Modern + Modulation Modulasi + Modulation Rate Laju Modulasi + Mono Media Media Mono + Monochrome Monokrom + Monochrome toned cyan-blue Monokrom bernuansa biru sian + Monochrome toned warm brown Monokrom bernuansa cokelat hangat + Monoscopic Monoskopik + Monospace only Hanya monospace + More colour in the less saturated areas Lebih banyak warna di area yang kurang jenuh + Morph Cut Morph Cut + Mosaic Mozaik + Mosaic (Legacy) Mozaik (Lama) + Motion Blur Buram Gerak + Motion Camera Kamera Gerak + Motion Preset Preset Gerak + Motion Tween Tween Gerak + Move Pindah + Movie Film + Movie (audio and video) Film (audio dan video) + Multiband Compressor Kompresor Multiband + Multichannel Mono Media Media Mono Multikanal + Multiply Kalikan + Multitap Delay Delay Multitap + Music Musik + Mute input during timeline recording Bisukan input saat perekaman di linimasa + Name Nama + Narrow Sempit + Neon Color Warna Neon + Neon Wipe Sapuan Neon + Next to original media files Di samping berkas media asli + Night Malam + No Motion Tanpa Gerak + No Scroll Tanpa Gulir + No plugins are installed. Changes will take effect the next time you start FilmCraft. Tidak ada plugin yang terpasang. Perubahan akan berlaku saat Anda memulai FilmCraft berikutnya. + Noise Derau + Noise & Grain Derau & Butiran + Noise (Legacy) Derau (Lama) + Noise Level Level Derau + Noise Reduction/Restoration Pengurangan Derau/Restorasi + Noise Type Jenis Derau + Non-Additive Dissolve Larut Non-Aditif + Normal Normal + Notch 1 Notch 1 + Notch 2 Notch 2 + Notch 3 Notch 3 + Notch 4 Notch 4 + Notch 5 Notch 5 + Notch 6 Notch 6 + Notch Filter Filter Notch + Notch Width Lebar Notch + Number Of Echoes Jumlah Gema + Number of Harmonics Jumlah Harmonik + Obsolete Usang + Octagon Segi Delapan + Offset Offset + Offset (Turbulence) Offset (Turbulensi) + Offset From Offset Dari + Offset To Offset Ke + Omni Omni + On Nyala + On Original Image Pada Gambar Asli + On Transparent Pada Transparan + Open Air Udara Terbuka + Open Most Recent Buka yang Terbaru + Open in new window Buka di jendela baru + Open in place Buka di tempat + Open new tab Buka tab baru + Opens up the shadows Membuka bayangan + Order Urutan + Orientation Orientasi + Outer Luar + Outgoing Keluar + Output Output + Output Mapping Pemetaan Output + Outside Di Luar + Outside Color Warna Luar + Overflow Luapan + Overlay Overlay + Overshoot Overshoot + Page Peel Kelupas Halaman + Page Scroll Gulir Halaman + Page Turn Balik Halaman + Paint Bucket Ember Cat + Paint On Black Lukis di Atas Hitam + Paint On Original Image Lukis di Atas Gambar Asli + Paint On Transparent Lukis di Atas Transparan + Paint On White Lukis di Atas Putih + Paint Style Gaya Lukis + Paint Surface Permukaan Lukis + Pan (Y Axis) Pan (Sumbu Y) + Panel Wipe Sapuan Panel + Panels Panel + Panner - Ambisonics Panner - Ambisonik + Parade Parade + Parade (RGB) Parade (RGB) + Parametric Equalizer Equalizer Parametrik + Passband Ripple Riak Passband + Path Jalur + Path Points Titik Jalur + Pause Media Encoder queue during playback Jeda antrean Media Encoder selama pemutaran + Peak Frequency Frekuensi Puncak + Peak Gain Gain Puncak + Pedestal Pedestal + Pentagon Segi Lima + Per line Per baris + Percent Blur Persen Buram + Perspective Perspektif + Phase Difference Selisih Fase + Phaser Phaser + Phone Call Panggilan Telepon + Phosphor Fosfor + Phosphor Color Warna Fosfor + Photocopy Fotokopi + Photocopy Color Warna Fotokopi + Pillow Bantal + Pin All Sematkan Semua + Pin Horizontal Sematkan Horizontal + Pin Light Pin Light + Pin Vertical Sematkan Vertikal + Pinning Penyematan + Pitch Shifter Pengubah Nada + Plate Pelat + Plateau Wipe Sapuan Dataran + Plateaus Dataran + Plates Pelat + Play after rendering previews Putar setelah merender pratinjau + Play audio while scrubbing in Source and Program Monitors Putar audio saat scrub di Monitor Sumber dan Program + Playback Pemutaran + Playhead position determines trim monitor loop playback Posisi kepala putar menentukan pemutaran berulang monitor pangkas + Plugins Plugin + Podcast Voice Suara Podcast + Point 1 Titik 1 + Point 1 Color Warna Titik 1 + Point 1 Latitude Lintang Titik 1 + Point 1 Longitude Bujur Titik 1 + Point 2 Titik 2 + Point 2 Color Warna Titik 2 + Point 2 Latitude Lintang Titik 2 + Point 2 Longitude Bujur Titik 2 + Point 3 Titik 3 + Point 3 Color Warna Titik 3 + Point 3 Latitude Lintang Titik 3 + Point 3 Longitude Bujur Titik 3 + Point 4 Titik 4 + Point 5 Titik 5 + Point 6 Titik 6 + Point 7 Titik 7 + Point 8 Titik 8 + Points Titik + Policy Kebijakan + Pop Motion Gerak Pop + Portuguese Portugis + Position & Rotation Posisi & Rotasi + Position Amount Jumlah Posisi + Position, Scale & Rotation Posisi, Skala & Rotasi + Position, Scale, Rotation Posisi, Skala, Rotasi + Posterize Posterisasi + Posterize Time Posterisasi Waktu + Pre-Delay Pra-Delay + Prefix Awalan + Presence Lift Angkat Presence + Preserve Luminosity Pertahankan Luminositas + Preserve Scale Pertahankan Skala + ProcAmp ProcAmp + Projecting cap Ujung menonjol + Projects Proyek + Pull Motion Gerak Tarik + Pulls highlights and whites down Menurunkan sorotan dan putih + Push Dorong + Push (Legacy) Dorong (Lama) + Q Q + RAM reserved for other applications RAM yang dicadangkan untuk aplikasi lain + RGB RGB + RGB Split Pisah RGB + RGB-White RGB-Putih + Radial Radial + Radial Blur Buram Radial + Radial Ramp Gradasi Radial + Radial Wipe Sapuan Radial + Radial Wipe (Legacy) Sapuan Radial (Lama) + Radio Announcer Penyiar Radio + Radio Voice Suara Radio + Radius Radius + Ramp Gradasi + Ramp Shape Bentuk Gradasi + Random Acak + Random Seed Benih Acak + Range Rentang + Ray Sinar + Ray Color Warna Sinar + Ray Length Panjang Sinar + Rays Sinar + Rays Only Hanya Sinar + Read Baca + Rec. 2100 Rec. 2100 + Rec. 601 Rec. 601 + Rec. 709 Rec. 709 + Red Blurriness Keburaman Merah + Red Curve Kurva Merah + Red Offset Offset Merah + Red Power Pangkat Merah + Red Slope Kemiringan Merah + Red-Blue Merah-Biru + Red-Const Merah-Konst + Red-Green Merah-Hijau + Red-Red Merah-Merah + Reduce Axis Kurangi Sumbu + Reduce Interlace Flicker Kurangi Kedip Interlace + Reflected Terpantul + Reflection Angle Sudut Pantulan + Reflection Center Pusat Pantulan + Refraction Refraksi + Reframe Offset Offset Bingkai Ulang + Reframe Zoom Zoom Bingkai Ulang + Refresh growing Files Every Segarkan Berkas yang Bertambah Setiap + Relaxed Santai + Release Time Waktu Pelepasan + Relief Relief + Remaining Tersisa + Remove Fringe Hapus Pinggiran + Render Edit in Audition files to: Render berkas Edit in Audition ke: + Render audio when rendering video Render audio saat merender video + Repair Perbaikan + Repeat Edge Pixels Ulangi Piksel Tepi + Replace Color Ganti Warna + Replace With Clip From Source Monitor Ganti dengan Klip dari Monitor Sumber + Replicate Replikasi + Responsive Design - Position Desain Responsif - Posisi + Responsive Design - Time Desain Responsif - Waktu + Restore open sequences when opening projects Pulihkan sekuens yang terbuka saat membuka proyek + Result Hasil + Retro Retro + Reveal Nested Sequence Tampilkan Sekuens Bersarang + Reveal Original Image Tampilkan Gambar Asli + Reveal in Project Tampilkan di Proyek + Reverb Amount Jumlah Reverb + Reverse Balik + Right - Left Kanan - Kiri + Right - Right Kanan - Kanan + Right channel to device output Kanal kanan ke output perangkat + Right to left Kanan ke kiri + Ripple trim adds edits to keep both sides of trim in sync Pangkas ripple menambahkan edit agar kedua sisi pemangkasan tetap sinkron + Roll (Z Axis) Gulung (Sumbu Z) + Room Size Ukuran Ruangan + Rotation Amount Jumlah Rotasi + Roughen Kasarkan + Roughen Color Warna Kasar + Roughen Edges Kasarkan Tepi + Round cap Ujung bulat + Round join Sambungan bulat + Rounded Crop Potong Tepi Membulat + Rows Baris + Russian Rusia + Rusty Berkarat + Rusty Color Warna Karat + SFX SFX + Sample Rate Laju Sampel + Saturation at 50 % Saturasi pada 50 % + Save .cfa and .pek media cache files next to original media files when possible Simpan berkas cache media .cfa dan .pek di samping berkas media asli bila memungkinkan + Scale (Degrees) Skala (Derajat) + Scale Amount Jumlah Skala + Scale From Skala Dari + Scale Height Skala Tinggi + Scale Match Pencocokan Skala + Scale To Skala Ke + Scale Width Skala Lebar + Scale to frame size Skalakan ke ukuran bingkai + Scan Lines Garis Pindai + Scene Adegan + Scientific Filter Filter Ilmiah + Scratch disk location for Captured Audio Lokasi disk kerja untuk Audio Rekaman + Screen Layar + Seconds Detik + Segments Segmen + Semi-tones Semiton + Sequence Name Nama Sekuens + Sequence Timecode Kode Waktu Sekuens + Set focus on the Timeline when performing Insert/Overwrite edits Fokuskan ke Linimasa saat melakukan edit Sisipkan/Timpa + Set to frame size Atur ke ukuran bingkai + Setting Pengaturan + Shadow Angle Sudut Bayangan + Shadow Blue Balance Keseimbangan Biru Bayangan + Shadow Blur Buram Bayangan + Shadow Color Warna Bayangan + Shadow Distance Jarak Bayangan + Shadow Green Balance Keseimbangan Hijau Bayangan + Shadow Only Hanya Bayangan + Shadow Opacity Opasitas Bayangan + Shadow Red Balance Keseimbangan Merah Bayangan + Shadow Size Ukuran Bayangan + Shadow Tint Rona Bayangan + Shadows Lightness Kecerahan Bayangan + Shake Guncang + Shape Dissolve Larut Bentuk + Shape Flow Aliran Bentuk + Shape Size Ukuran Bentuk + Shapes Bentuk + Shard Size Ukuran Serpihan + Sharp Colors Warna Tajam + Sharpen Amount Jumlah Penajaman + Shift Center To Geser Pusat ke + Shift clips that overlap trim point during ripple trimming Geser klip yang tumpang tindih dengan titik pangkas saat pemangkasan ripple + Shot Shot + Show "Project:" and "Bin:" in Project panel tab Tampilkan "Proyek:" dan "Bin:" di tab panel Proyek + Show Clip Mismatch Warning dialog Tampilkan dialog Peringatan Ketidakcocokan Klip + Show Event Indicator Tampilkan Indikator Peristiwa + Show Focus Area Tampilkan Area Fokus + Show Home Tampilkan Beranda + Show Mask Tracker Preview Tampilkan Pratinjau Pelacak Masker + Show Only Noise Tampilkan Hanya Derau + Show Open Dialog Tampilkan Dialog Buka + Show Project Load Error Dialog Tampilkan Dialog Galat Memuat Proyek + Show Rich Tool Tips Tampilkan Tooltip Lengkap + Show Tool Tips Tampilkan Tooltip + Show Workspace Reset Warning dialog on double click Tampilkan dialog Peringatan Atur Ulang Ruang Kerja saat klik ganda + Show system compatibility issues at startup Tampilkan masalah kompatibilitas sistem saat memulai + Shrink Susutkan + Shutter Angle Sudut Rana + Similarity Kemiripan + Simple Notch Filter Filter Notch Sederhana + Simple Parametric EQ EQ Parametrik Sederhana + Simple Text Teks Sederhana + Single-band Compressor Kompresor Pita Tunggal + Sinusoidal Sinusoidal + Skew Miring + Skew Axis Sumbu Kemiringan + Slats Bilah + Slice Irisan + Slices Irisan-irisan + Slide Geser + Slide (Legacy) Geser (Lama) + Slope Kemiringan + Slow Lambat + Slower Motion Gerak Lebih Lambat + Small Room Ruangan Kecil + Smart Limit Batas Cerdas + Smart Tools Alat Cerdas + Smart quotes Tanda kutip cerdas + Smooth Halus + Smooth Motion Gerak Halus + Smooth Scroll Gulir Halus + Smoothness Kehalusan + Snap playhead in Timeline when Snap is enabled Snap kepala putar di Linimasa saat Snap diaktifkan + Soft Lembut + Soft Clamp Pembatasan Lembut + Soft Edges (Best Quality) Tepi Lembut (Kualitas Terbaik) + Soft Light Cahaya Lembut + Soft Wipe Sapuan Lembut + Soften Harshness Lembutkan Kekasaran + Softness Kelembutan + Solarize Solarisasi + Solid Colors Warna Solid + Source Point Titik Sumber + Source Text Teks Sumber + Spacer Pengatur Jarak + Spanish Spanyol + Speaker Angle Sudut Speaker + Speaker Labeling Pelabelan Pembicara + Special Khusus + Special Effects Efek Khusus + Speech model Model ucapan + Speed Kecepatan + Spherize Bulatkan + Spiky Berduri + Spill Tumpahan + Spill Suppression Penekanan Tumpahan + Spin Putaran + Spin Direction Arah Putaran + Spin Motion Gerak Putar + Split Belah + Split (Legacy) Belah (Lama) + Spotlight Lampu Sorot + Spread Sebaran + Spring Motion Gerak Pegas + Square Persegi + Square (Width Only) Persegi (Hanya Lebar) + Square 1:1 Persegi 1:1 + Stabilization Stabilisasi + Stabilize Only Hanya Stabilkan + Stabilize, Crop Stabilkan, Potong Tepi + Stabilize, Crop, Auto-scale Stabilkan, Potong Tepi, Skala Otomatis + Stabilize, Synthesize Edges Stabilkan, Sintesis Tepi + Stages Tahap + Star Bintang + Star Wipe Sapuan Bintang + Start Angle Sudut Awal + Start Color Warna Awal + Start Point Titik Awal + Start at 0 Mulai dari 0 + Start at 1 Mulai dari 1 + Start of Ramp Awal Gradasi + Start with an Empty Project Mulai dengan Proyek Kosong + Starting Intensity Intensitas Awal + Static Plates Pelat Statis + Step forward/back many Maju/mundur banyak + Stereo Expand Perluas Stereo + Stereo Expander Pelebar Stereo + Stereo Imagery Citra Stereo + Stereo Media Media Stereo + Stereo Phasing Fase Stereo + Stereoscopic - Over/Under Stereoskopik - Atas/Bawah + Stereoscopic - Side by Side Stereoskopik - Berdampingan + Still Diam + Still Image Default Duration Durasi Bawaan Gambar Diam + Stone Church Gereja Batu + Stopband Attenuation Atenuasi Stopband + Straight Color Warna Lurus + Strength Kekuatan + Stretch Regangkan + Stretch Gradient to Fit Regangkan Gradien agar Pas + Stretch Map to Fit Regangkan Peta agar Pas + Stretch Width or Height Regangkan Lebar atau Tinggi + Stretch Wipe Sapuan Regang + Stretch to Fit Frame Regangkan agar Pas Bingkai + Stripe Garis + Stripes Garis-garis + Strobe Color Warna Strobo + Strobe Duration (secs) Durasi Strobo (dtk) + Strobe Light Lampu Strobo + Strobe Period (secs) Periode Strobo (dtk) + Stroke 2 Color Warna Garis Tepi 2 + Stroke 2 Type Jenis Garis Tepi 2 + Stroke 2 Width Lebar Garis Tepi 2 + Stroke Angle Sudut Sapuan + Stroke Color Warna Garis Tepi + Stroke Density Kerapatan Sapuan + Stroke Length Panjang Sapuan + Stroke Length (secs) Panjang Sapuan (dtk) + Stroke Only Hanya Garis Tepi + Stroke Randomness Keacakan Sapuan + Stroke Type Jenis Garis Tepi + Stroke Width Lebar Garis Tepi + Stroke line cap Ujung garis tepi + Stroke line join Sambungan garis tepi + Studio Reverb Reverb Studio + Stylize Stilisasi + Subclip End Akhir Subklip + Subclip Start Awal Subklip + Subspace Warp Warp Subruang + Subtitle Subtitel + Subtract Kurangi + Sum multichannel outputs to mono in Source Monitor Jumlahkan output multikanal ke mono di Monitor Sumber + Super Narrow Ultra Sempit + Surface Gloss Kilap Permukaan + Surface Material Bahan Permukaan + Surround Reverb Reverb Surround + Swap Tukar + Swap Channels Tukar Kanal + Swivel Ayun Poros + Synthetic Sintetis + TV Power Daya TV + Tape Pita + Tape Name Nama Pita + Tape/Tube Pita/Tabung + Target Aspect Aspek Target + Target Color Warna Target + Target Field of View Bidang Pandang Target + Target Loudness Kenyaringan Target + Teal & Orange Toska & Oranye + Telephone Telepon + Teletext Teleteks + Template Properties Properti Templat + Text Animator Animator Teks + Text Color Warna Teks + 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. Cache media menyimpan pratinjau render dari proyek yang belum disimpan dan berkas lain yang dapat dibuat ulang oleh FilmCraft. Menghapusnya selalu aman; FilmCraft membangunnya kembali bila diperlukan. + Thickness Ketebalan + Tile Gradient Gradien Ubin + Tilt Miringkan + Tilt (X Axis) Miringkan (Sumbu X) + Time Waktu + Time and Pitch Waktu dan Nada + Timecode Conversion Konversi Kode Waktu + Timeline Mouse Scrolling Gulir Mouse di Linimasa + Timeline Playback Auto-Scrolling Gulir Otomatis Pemutaran Linimasa + Tint Color Warna Rona + To Ke + Tolerance Toleransi + Tone Nada + Top Center Tengah Atas + Top Left Kiri Atas + Top Right Kanan Atas + Touch Touch + Track Matte Key Key Matte Trek + Transcription Transkripsi + Transcription preferences Preferensi transkripsi + Transform Transformasi + Transformers Trafo + Transience Transien + Transition Completion Penyelesaian Transisi + Transition Softness Kelembutan Transisi + Transparency Transparansi + Trash Sampah + Travel Motion Gerak Perjalanan + Treble Treble + Triangle Segitiga + Trim Pangkas + Tube Tabung + Tube-modeled Compressor Kompresor Model Tabung + Tubular Tubular + Turbulent Turbulen + Turbulent Displace Perpindahan Turbulen + Turbulent Sharp Turbulen Tajam + Turbulent Smooth Turbulen Halus + Turbulent Smoother Turbulen Lebih Halus + Tweed Tweed + Twirl Pusaran + Twirl (Legacy) Pusaran (Lama) + Twirl Center Pusat Pusaran + Twirl Radius Radius Pusaran + Twist Pilin + Twist Smoother Pilin Lebih Halus + Typewriter Mesin Tik + Ultra Key Ultra Key + Uniform Scale Skala Seragam + Uniform Spacing Jarak Seragam + Unsharp Mask Unsharp Mask + Update it at least every Perbarui setidaknya setiap + Update timeline dynamically during ripple edits. Perbarui linimasa secara dinamis selama edit ripple. + Upper Frequency Frekuensi Atas + Upper Left Kiri Atas + Upper Right Kanan Atas + Use Color Noise Gunakan Derau Warna + Use End Color Gunakan Warna Akhir + Use File Gunakan Berkas + Use Glow Color Gunakan Warna Pendar + Use Media Source Gunakan Sumber Media + Use Tint Color Gunakan Warna Rona + Utility Utilitas + VHS Damage Kerusakan VHS + VR Blur Buram VR + VR Chroma Leaks Kebocoran Kroma VR + VR Chromatic Aberrations Aberasi Kromatik VR + VR Color Gradients Gradien Warna VR + VR De-Noise Hilangkan Derau VR + VR Digital Glitch Glitch Digital VR + VR Fractal Noise Derau Fraktal VR + VR Glow Pendar VR + VR Gradient Wipe Sapuan Gradien VR + VR Iris Wipe Sapuan Iris VR + VR Light Leaks Kebocoran Cahaya VR + VR Light Rays Sinar Cahaya VR + VR Mobius Zoom Zoom Mobius VR + VR Plane to Sphere Bidang ke Bola VR + VR Projection Proyeksi VR + VR Random Blocks Blok Acak VR + VR Rotate Sphere Putar Bola VR + VR Sharpen Pertajam VR + VR Spherical Blur Buram Bola VR + Validate Content Credentials on import Validasi Kredensial Konten saat impor + Vectorscope (YUV) Vektorskop (YUV) + Vectorscope + Waveform YC Vektorskop + Bentuk Gelombang YC + Vectorscope HLS Vektorskop HLS + Vectorscope YUV Vektorskop YUV + Venetian Blinds Kerai Venesia + Vertical 4:5 Vertikal 4:5 + Vertical 9:16 Vertikal 9:16 + Vertical Alignment Perataan Vertikal + Vertical Blocks Blok Vertikal + Vertical Decentering Pergeseran Pusat Vertikal + Vertical Displacement Perpindahan Vertikal + Vertical Flip Balik Vertikal + Vertical Margin Margin Vertikal + Very Narrow Sangat Sempit + Video 1 Video 1 + Video 2 Video 2 + Video 3 Video 3 + Video 4 Video 4 + Video 5 Video 5 + Video 6 Video 6 + Video 7 Video 7 + Video 8 Video 8 + Video Codec Codec Video + Video Duration Durasi Video + Video Effects Efek Video + Video In Point Titik Masuk Video + Video Info Info Video + Video Limiter Limiter Video + Video Out Point Titik Keluar Video + Video Transition Default Duration Durasi Bawaan Transisi Video + Video Transitions Transisi Video + Video Usage Penggunaan Video + Vignette Vinyet + Vivid Light Cahaya Terang + Vocal Booth Bilik Vokal + Vocal Enhancer Penguat Vokal + Voice Suara + Voice in a Hall Suara di Aula + Volumetric Rays Sinar Volumetrik + Warm Film Film Hangat + Warm Room Ruangan Hangat + Warm Voice Suara Hangat + Warm print stock with gentle roll-off Stok cetak hangat dengan roll-off lembut + Warm, soft late-afternoon light Cahaya sore yang hangat dan lembut + Warning Peringatan + Warp Stabilizer Warp Stabilizer + Wave Gelombang + Wave Height Tinggi Gelombang + Wave Speed Kecepatan Gelombang + Wave Warp Lengkung Gelombang + Wave Width Lebar Gelombang + Waveform Bentuk Gelombang + Waveform (Luma) Bentuk Gelombang (Luma) + Waveform (RGB) Bentuk Gelombang (RGB) + Wavelength Panjang Gelombang + Wet Basah + Wet Out Output Basah + When Opening a Project Saat Membuka Proyek + Whip Cambuk + Whip (Legacy) Cambuk (Lama) + Whisper base (balanced) Whisper base (seimbang) + Whisper small (most accurate) Whisper small (paling akurat) + Whisper tiny (fastest) Whisper tiny (tercepat) + White Putih + White Input Level Level Input Putih + White Is High Putih Berarti Tinggi + Wide Outdoor Luar Ruangan Luas + Widener Pelebar + Width Lebar + Wiggle Goyang + Wipe Sapuan + Wipe Angle Sudut Sapuan + Wonder Glow Pendar Ajaib + Wrap Back Bungkus Balik + Write Tulis + Write XMP ID to files on import Tulis ID XMP ke berkas saat impor + Write clip markers to XMP Tulis penanda klip ke XMP + Write-on Tulis Muncul + X Rotation Rotasi X + Y Rotation Rotasi Y + YC YC + YC no Chroma YC tanpa Kroma + YUV YUV + Yellow-green highlights and blue-lifted shadows from crossed channel curves Sorotan kuning-hijau dan bayangan kebiruan dari kurva kanal bersilang + Z Position Posisi Z + Z Rotation Rotasi Z + Zoom Zoom + Zoom Amount Jumlah Zoom + Zoom Blur Buram Zoom + Zoom Strength Kekuatan Zoom + days hari + frames bingkai + milliseconds milidetik + minute(s) menit + second(s) detik + seconds detik + AAF… AAF… + Audio… Audio… + Avid Log Exchange… Avid Log Exchange… + Color… Warna… + EDL… EDL… + Edit Workspaces… Edit Ruang Kerja… + FCPXML… FCPXML… + Final Cut Pro XML… Final Cut Pro XML… + General… Umum… + OMF… OMF… + OpenTimelineIO… OpenTimelineIO… + Save Changes to this Workspace Simpan Perubahan pada Ruang Kerja Ini + Save as New Workspace… Simpan sebagai Ruang Kerja Baru… + Create Search Bin Buat Bin Pencarian + Edit Offline File Edit Berkas Offline + Source Settings Pengaturan Sumber + Update Metadata Perbarui Metadata + Automate To Sequence Otomatiskan ke Sekuens + Scene Edit Detection Deteksi Pergantian Adegan + Analyze Analisis + Normalize Mix Track Normalisasi Trek Mix + Simplify Sequence Sederhanakan Sekuens + Simplify Sederhanakan + Transcribe Sequence Transkripsikan Sekuens + Save as Template Simpan sebagai Templat + Flash Cue Marker Penanda Isyarat Flash + System Compatibility Report Laporan Kompatibilitas Sistem + no GPU adapter: the CPU compositor is used tidak ada adaptor GPU: kompositor CPU digunakan + Avid Log Exchange Avid Log Exchange + FilmCraft Project Proyek FilmCraft + {name} Selection.fcproj {name} Pilihan.fcproj + {name} (Simplified) {name} (Disederhanakan) + Find in: Cari di: + Find: Cari: + Search: Cari: + Clip Names and Markers Nama Klip dan Penanda + Match: Cocokkan: + All Semua + Any Salah Satu + Case Sensitive Peka Huruf Besar/Kecil + Scratch Disks Disk Kerja + Ingest Settings Pengaturan Ingest + Captured and Generated: Direkam dan Dibuat: + Video Previews: Pratinjau Video: + Audio Previews: Pratinjau Audio: + Project Auto Save: Simpan Otomatis Proyek: + Same as Project Sama dengan Proyek + Path: {path} Jalur: {path} + Transcode Transkode + Copy and Create Proxies Salin dan Buat Proksi + Action: Tindakan: + Destination: Tujuan: + Next to the media Di samping media + Video Rendering and Playback Rendering dan Pemutaran Video + Renderer: Perender: + Display Format: Format Tampilan: + Milliseconds Milidetik + Capture Rekam + Capture Format: Format Rekam: + Action and Title Safe Areas Area Aman Aksi dan Judul + Title Safe Area Area Aman Judul + Action Safe Area Area Aman Aksi + Total Duration Durasi Total + Average Data Rate Laju Data Rata-rata + Colour Warna + {codec} · {rate} Hz · {n} channel(s) {codec} · {rate} Hz · {n} kanal + Media Name: Nama Media: + Scene: Adegan: + Shot/Take: Shot/Take: + Log Note: Catatan Log: + Codec: {codec} Codec: {codec} + Write the metadata of the selected clip(s) ({n}) to XMP files next to their media? Tulis metadata klip terpilih ({n}) ke berkas XMP di samping medianya? + Existing XMP files written by other applications are left unchanged. Berkas XMP yang ditulis oleh aplikasi lain dibiarkan tidak berubah. + To {name} Ke {name} + Ordering: Pengurutan: + Sort Order Urutan Sortir + Selection Order Urutan Pilihan + Placement: Penempatan: + Sequentially Berurutan + At Unnumbered Markers Di Penanda Tanpa Nomor + Method: Metode: + Insert Edit Edit Sisip + Overwrite Edit Edit Timpa + Clip Overlap: Tumpang Tindih Klip: + Frames per Still: Bingkai per Gambar Diam: + Transitions Transisi + Apply Default Audio Transition Terapkan Transisi Audio Bawaan + Apply Default Video Transition Terapkan Transisi Video Bawaan + Ignore Options Opsi Abaikan + Ignore Audio Abaikan Audio + Ignore Video Abaikan Video + Apply a cut at each detected cut point Terapkan potongan di setiap titik potong yang terdeteksi + Create a subclip for each detected cut point Buat subklip untuk setiap titik potong yang terdeteksi + Generate clip markers at each detected cut point Buat penanda klip di setiap titik potong yang terdeteksi + Sensitivity: Sensitivitas: + Normalize Mix Track to: Normalisasi Trek Mix ke: + Disabled clips Klip nonaktif + Empty tracks Trek kosong + Gaps Celah + Video effects Efek video + Audio effects Efek audio + Text (graphics and captions) Teks (grafis dan takarir) + Clips Klip + Move clips to the lowest track possible Pindahkan klip ke trek serendah mungkin + Keep: Pertahankan: + Video and Audio Video dan Audio + Video Only Hanya Video + Audio Only Hanya Audio + Language: Bahasa: + Audio on track {t} Audio di trek {t} + Audio analysis: Analisis audio: + Recognize when different speakers are talking Kenali saat pembicara yang berbeda sedang berbicara + Template Name: Nama Templat: + Comments: Komentar: + (unavailable) (tidak tersedia) + Decoders Dekoder + Containers Kontainer + Export formats Format ekspor + Match {n} of {total} Kecocokan {n} dari {total} + Saved template {path} Templat disimpan: {path} + Scene Edit Detection: analysing in the background (see Progress) Deteksi Pergantian Adegan: menganalisis di latar belakang (lihat Kemajuan) + Delete Search Bin Hapus Bin Pencarian + (removed from {list}) (dihapus dari {list}) + {key} can't be used as a shortcut {key} tidak dapat digunakan sebagai pintasan + Keyboard Shortcuts Pintasan Keyboard + {active} (modified) {active} (diubah) + Loaded “{name}” “{name}” dimuat + My Shortcuts Pintasan Saya + Deleted preset “{active}” Preset “{active}” dihapus + Export… Ekspor… + Exported to {path} Diekspor ke {path} + Export: no file chosen (agents: shortcuts.export {path}) Ekspor: tidak ada berkas yang dipilih (agen: shortcuts.export {path}) + Imported “{name}” “{name}” diimpor + Import: no file chosen (agents: shortcuts.import {path}) Impor: tidak ada berkas yang dipilih (agen: shortcuts.import {path}) + Keyboard: US (macOS) Keyboard: US (macOS) + Keyboard: US Keyboard: US + Save preset as: Simpan preset sebagai: + Commands: Perintah: + {panel} Panel Panel {panel} + Application shortcuts work whatever panel has focus Pintasan aplikasi berfungsi di panel mana pun yang sedang fokus + Panel shortcuts override application shortcuts while that panel has focus Pintasan panel menggantikan pintasan aplikasi selama panel itu sedang fokus + Modifier held / selected Pengubah ditahan / dipilih + Search commands or keys Cari perintah atau tombol + Selected: {command} Dipilih: {command} + Command Perintah + Shortcut Pintasan + Type a shortcut… (Esc cancels) Ketik pintasan… (Esc membatalkan) + Add another shortcut Tambah pintasan lain + No matching commands Tidak ada perintah yang cocok + Key: Tombol: + Modifiers Pengubah + Nothing is assigned to this key Tidak ada yang ditetapkan ke tombol ini + Click a key on the keyboard to see its shortcuts Klik tombol pada keyboard untuk melihat pintasannya + Assign {key} to {command} Tetapkan {key} ke {command} + Conflict: {key} is assigned to {commands} ({ctx}) Konflik: {key} ditetapkan ke {commands} ({ctx}) + and dan + Click a command's Shortcut cell and press keys to assign it; + adds another shortcut. Klik sel Pintasan suatu perintah lalu tekan tombol untuk menetapkannya; + menambahkan pintasan lain. + Cleared {n} shortcut(s) of {command} {n} pintasan dari {command} dihapus + No export preset named `{name}` Tidak ada preset ekspor bernama `{name}` + Open a sequence to export Buka sekuens untuk mengekspor + Destinations Tujuan + Media File Berkas Media + Queue ({n}) Antrean ({n}) + Send exports here with Send to Queue, then start the queue. Kirim ekspor ke sini dengan Kirim ke Antrean, lalu mulai antrean. + Ready Siap + Encoding {n}% Mengenkode {n}% + Start Queue Mulai Antrean + Stop Berhenti + Clear Finished Hapus yang Selesai + Not supported by FilmCraft's encoders yet Belum didukung oleh enkoder FilmCraft + Sequence name Nama sekuens + Folder Folder + Preset Preset + More presets… Preset lainnya… + Multiplexer Multiplexer + The sequence has no caption track. Sekuens tidak memiliki trek takarir. + Export Options Opsi Ekspor + Burn Captions Into Video Tanamkan Takarir ke Video + Create Sidecar File Buat Berkas Pendamping + File Format Format Berkas + SubRip Subtitle (.srt) Subtitel SubRip (.srt) + WebVTT (.vtt) WebVTT (.vtt) + Title Judul + Creator Pembuat + Copyright Hak Cipta + Image Gambar + Frame Size Ukuran Bingkai + Match Source Cocokkan Sumber + Field Order Urutan Field + Aspect Aspek + Profile Profil + Bitrate Encoding Enkode Bitrate + Target Bitrate Bitrate Target + Adaptive ({bpp} bits per pixel) Adaptif ({bpp} bit per piksel) + Maximum Bitrate Bitrate Maksimum + Quality (CRF) Kualitas (CRF) + Key Frame Distance Jarak Bingkai Kunci + Quality Kualitas + Render at Maximum Depth Render pada Kedalaman Maksimum + Use Maximum Render Quality Gunakan Kualitas Render Maksimum + Export Audio Ekspor Audio + Audio Format Format Audio + Uncompressed (PCM) Tanpa Kompresi (PCM) + Match Source ({seq_sr} Hz) Cocokkan Sumber ({seq_sr} Hz) + Channels Kanal + Audio Bitrate Bitrate Audio + Sample Size Ukuran Sampel + 16 bit 16 bit + 24 bit 24 bit + Size / Opacity Ukuran / Opasitas + Image Overlay Overlay Gambar + PNG or JPEG file Berkas PNG atau JPEG + Name Overlay Overlay Nama + Timecode Overlay Overlay Kode Waktu + Luma Min / Max Luma Min / Maks + Loudness Normalization Normalisasi Kenyaringan + True Peak Limit Batas True Peak + Audio only Hanya audio + Scaling Penskalaan + Scale To Fit Skalakan agar Pas + Scale To Fill Skalakan untuk Mengisi + Stretch To Fill Regangkan untuk Mengisi + Summary Ringkasan + Sequence, {w}x{h}, {fps} fps, {rate} Hz Sekuens, {w}x{h}, {fps} fps, {rate} Hz + Estimated file size: {size} Perkiraan ukuran berkas: {size} + Send to Queue Kirim ke Antrean + Exporting {path} Mengekspor {path} + Added to the export queue Ditambahkan ke antrean ekspor + Preset Manager Manajer Preset + Search presets Cari preset + Favorites only Hanya favorit + Show in the Preset menu Tampilkan di menu Preset + User Pengguna + Save current settings as: Simpan pengaturan saat ini sebagai: + Export presets Ekspor preset + Import: no file chosen (agents: export.presets.import {path}) Impor: tidak ada berkas yang dipilih (agen: export.presets.import {path}) + Export: no file chosen (agents: export.presets.export {path, names}) Ekspor: tidak ada berkas yang dipilih (agen: export.presets.export {path, names}) + Quick Export: open a sequence first Ekspor Cepat: buka sekuens terlebih dahulu + File Name & Location Nama & Lokasi Berkas + Quick Export: {path} Ekspor Cepat: {path} + Graphics problem: switched to software compositing Masalah grafis: beralih ke pengomposisian perangkat lunak + GPU acceleration is unavailable; preview renders on the CPU Akselerasi GPU tidak tersedia; pratinjau dirender di CPU + Exporting Mengekspor + Click to select, or click in empty space and drag to marquee select. Use Shift, Opt, and Cmd for other options. Klik untuk memilih, atau klik di ruang kosong lalu seret untuk memilih dengan bingkai. Gunakan Shift, Opt, dan Cmd untuk opsi lain. + Click to select all clips to the right in all tracks. Shift-click for a single track. Klik untuk memilih semua klip di sebelah kanan di semua trek. Shift-klik untuk satu trek. + Click to select all clips to the left in all tracks. Shift-click for a single track. Klik untuk memilih semua klip di sebelah kiri di semua trek. Shift-klik untuk satu trek. + Drag an edit point to ripple trim; later clips move to keep the gap closed. Seret titik edit untuk pangkas ripple; klip berikutnya bergeser agar celah tetap tertutup. + Drag an edit point to roll it: the out of one clip and the in of the next move together. Seret titik edit untuk roll: titik keluar satu klip dan titik masuk klip berikutnya bergeser bersama. + Drag an edge to change the clip's speed so it fills the new duration. Seret tepi untuk mengubah kecepatan klip agar mengisi durasi baru. + Drag the edge of a music clip to remix it to the new duration at musically matching beats. Seret tepi klip musik untuk me-remix-nya ke durasi baru pada ketukan yang sesuai secara musikal. + Click to split a clip. Shift-click to split all tracks. Klik untuk membelah klip. Shift-klik untuk membelah semua trek. + Drag a clip to slip its source in/out without moving it. Seret klip untuk menggeser masuk/keluar sumbernya tanpa memindahkannya. + Drag a clip to slide it between its neighbours. Seret klip untuk menggesernya di antara klip tetangganya. + Drag to scroll the timeline. Seret untuk menggulir linimasa. + Click to zoom in; Opt-click to zoom out. Klik untuk memperbesar; Opt-klik untuk memperkecil. + FilmCraft hit an error FilmCraft mengalami galat + Details: {path} Detail: {path} + Opened {url} Membuka {url} + (empty) (kosong) + Step Back 1 Frame Mundur 1 Bingkai + Step Forward 1 Frame Maju 1 Bingkai + Match Playhead Cocokkan Kepala Putar + Gang to Program Gabungkan ke Program + AAC Audio Audio AAC + AAC Audio 48 kHz 320 kbps Audio AAC 48 kHz 320 kbps + MPEG-4 audio (.m4a), stereo 48 kHz AAC-LC at 320 kbps Audio MPEG-4 (.m4a), stereo 48 kHz AAC-LC pada 320 kbps + AIFF AIFF + AIFF 48 kHz 16-bit AIFF 48 kHz 16-bit + AIFF, stereo 48 kHz 16-bit PCM AIFF, PCM stereo 48 kHz 16-bit + APV APV + APV 422-10 APV 422-10 + APV 422-12 APV 422-12 + Anamorphic 2:1 (2.0) Anamorfik 2:1 (2.0) + Animated GIF GIF Animasi + Animated GIF 640×360 GIF Animasi 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 dengan Alfa + Apple ProRes 4444 XQ Apple ProRes 4444 XQ + Application Aplikasi + Apply Guide Template Terapkan Templat Garis Bantu + Auto Save Now Simpan Otomatis Sekarang + Auto-Match Loudness Cocokkan Kenyaringan Otomatis + 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 gaya Avid: berkas gambar DNxHR HQ ditambah satu berkas PCM 24-bit 48 kHz per kanal (_A1, _A2…) + BMP BMP + BMP Sequence Urutan BMP + Baseline Baseline + Begin Text Editing for a Graphic Layer Mulai Mengedit Teks pada Lapisan Grafis + Begins With Diawali Dengan + Blur In Buram Masuk + Blur Out Buram Keluar + Brightens inside a feathered circular mask Mencerahkan bagian dalam masker lingkaran ber-feather + Broadcast Siaran + CBR CBR + Cancel Dynamic Trim Batalkan Pangkas Dinamis + Cancel Queued Export Batalkan Ekspor dalam Antrean + Center Left Kiri Tengah + Center Right Kanan Tengah + Center align text Rata tengah teks + Change Mask Ubah Masker + Clear All Events Hapus Semua Peristiwa + Clear Edit Point Selection Hapus Pilihan Titik Edit + Clear Finished Exports Hapus Ekspor yang Selesai + Clear Poster Frame Hapus Bingkai Poster + Close Bin Tutup Bin + Close Sequence Tutup Sekuens + Contains Berisi + Cut to Camera 1 Potong ke Kamera 1 + Cut to Camera 2 Potong ke Kamera 2 + Cut to Camera 3 Potong ke Kamera 3 + Cut to Camera 4 Potong ke Kamera 4 + Cut to Camera 5 Potong ke Kamera 5 + Cut to Camera 6 Potong ke Kamera 6 + Cut to Camera 7 Potong ke Kamera 7 + Cut to Camera 8 Potong ke Kamera 8 + Cut to Camera 9 Potong ke Kamera 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 Menggelapkan bagian luar masker oval ber-feather + Decrease Audio Tracks Height Kurangi Tinggi Trek Audio + Decrease Clip Volume Kurangi Volume Klip + Decrease Clip Volume Many Kurangi Volume Klip Banyak + Decrease Font Size by Five Units Kurangi Ukuran Font Lima Satuan + Decrease Font Size by One Unit Kurangi Ukuran Font Satu Satuan + Decrease Leading by Five Units Kurangi Spasi Baris Lima Satuan + Decrease Leading by One Unit Kurangi Spasi Baris Satu Satuan + Decrease Video Tracks Height Kurangi Tinggi Trek Video + Delete Captions Hapus Takarir + Delete Graphic Layer Hapus Lapisan Grafis + Delete Guide Template Hapus Templat Garis Bantu + Delete Media Cache Files Hapus Berkas Cache Media + Discard Unsaved Changes Buang Perubahan yang Belum Disimpan + Does Not Contain Tidak Berisi + Download Speech Model Unduh Model Ucapan + Dynamic Trim Stop Hentikan Pangkas Dinamis + Edit Caption Text Edit Teks Takarir + Ellipse Tool Alat Elips + Ends With Diakhiri Dengan + Entire Source Seluruh Sumber + EssentialSound EssentialSound + Export Frame Ekspor Bingkai + Extend Next Edit To Playhead Perpanjang Edit Berikutnya ke Kepala Putar + Extend Previous Edit To Playhead Perpanjang Edit Sebelumnya ke Kepala Putar + Extend Selected Edit to Playhead Perpanjang Edit Terpilih ke Kepala Putar + Extend Selection Down Perluas Pilihan ke Bawah + Extend Selection Left Perluas Pilihan ke Kiri + Extend Selection Right Perluas Pilihan ke Kanan + Extend Selection Up Perluas Pilihan ke Atas + Fade In Fade In + Gaussian Blur from 0 to 60 over the last second Buram Gaussian dari 0 ke 60 selama detik terakhir + Gaussian Blur from 60 to 0 over the first second Buram Gaussian dari 60 ke 0 selama detik pertama + Generate Ducking Keyframes Buat Bingkai Kunci Ducking + Go to Next Edit Point Ke Titik Edit Berikutnya + Go to Next Edit Point on Any Track Ke Titik Edit Berikutnya di Trek Mana Pun + Go to Previous Edit Point Ke Titik Edit Sebelumnya + Go to Previous Edit Point on Any Track Ke Titik Edit Sebelumnya di Trek Mana Pun + Go to Selected Clip End Ke Akhir Klip Terpilih + Go to Selected Clip Start Ke Awal Klip Terpilih + Go to Sequence End Ke Akhir Sekuens + Go to Sequence Start Ke Awal Sekuens + H.264 H.264 + AV1 AV1 + H.264 1920×1080 for video sharing sites, VBR 1 pass, 16 Mbps H.264 1920×1080 untuk situs berbagi video, VBR 1 pass, 16 Mbps + H.264 1920×1080, VBR 1 pass, 20 Mbps target, AAC 320 kbps H.264 1920×1080, VBR 1 pass, target 20 Mbps, AAC 320 kbps + H.264 1920×1080, VBR 2 pass, 18 Mbps H.264 1920×1080, VBR 2 pass, 18 Mbps + H.264 3840×2160 for video sharing sites, VBR 1 pass, 45 Mbps H.264 3840×2160 untuk situs berbagi video, VBR 1 pass, 45 Mbps + H.264 3840×2160, VBR 1 pass, 60 Mbps target, AAC 320 kbps H.264 3840×2160, VBR 1 pass, target 60 Mbps, AAC 320 kbps + H.264 3840×2160, VBR 2 pass, 50 Mbps H.264 3840×2160, VBR 2 pass, 50 Mbps + H.264 9:16 portrait for short-form social video, scaled to fill, 12 Mbps H.264 potret 9:16 untuk video sosial pendek, diskalakan untuk mengisi, 12 Mbps + H.264 at the sequence's frame size and rate; 0.05 bits per pixel H.264 pada ukuran dan laju bingkai sekuens; 0,05 bit per piksel + H.264 at the sequence's frame size and rate; 0.1 bits per pixel H.264 pada ukuran dan laju bingkai sekuens; 0,1 bit per piksel + H.264 at the sequence's frame size and rate; bitrate adapts to the frame size (0.2 bits per pixel) H.264 pada ukuran dan laju bingkai sekuens; bitrate menyesuaikan ukuran bingkai (0,2 bit per piksel) + HD Anamorphic 1080 (1.333) HD Anamorfik 1080 (1.333) + Hand Tool Alat Tangan + High Quality 1080p HD Kualitas Tinggi 1080p HD + High Quality 2160p 4K Kualitas Tinggi 2160p 4K + Image Sequence Urutan Gambar + Increase Audio Tracks Height Tambah Tinggi Trek Audio + Increase Clip Volume Naikkan Volume Klip + Increase Clip Volume Many Naikkan Volume Klip Banyak + Increase Font Size by Five Units Perbesar Ukuran Font Lima Satuan + Increase Font Size by One Unit Perbesar Ukuran Font Satu Satuan + Increase Leading by Five Units Tambah Spasi Baris Lima Satuan + Increase Leading by One Unit Tambah Spasi Baris Satu Satuan + Increase Video Tracks Height Tambah Tinggi Trek Video + Large blocks resolving to the picture over the first second Blok besar yang berubah menjadi gambar selama detik pertama + Left align text Rata kiri teks + Looping GIF, 640×360 at 15 fps GIF berulang, 640×360 pada 15 fps + Lower First Bawah Dulu + 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-bit 4:2:2), PCM 24-bit 48 kHz, kode waktu awal + MXF OP1a, H.264 High long GOP (0.3 bits per pixel), 24-bit 48 kHz PCM MXF OP1a, H.264 High long GOP (0,3 bit per piksel), PCM 24-bit 48 kHz + MXF OP1a, ProRes 422 HQ, 24-bit 48 kHz PCM, start timecode MXF OP1a, ProRes 422 HQ, PCM 24-bit 48 kHz, kode waktu awal + Main Main + Mark In (Hover Scrub) Tandai Masuk (Scrub saat Melayang) + Mark Out (Hover Scrub) Tandai Keluar (Scrub saat Melayang) + Masks Masker + Match Source – Adaptive High Bitrate Cocokkan Sumber – Bitrate Tinggi Adaptif + Match Source – Adaptive Low Bitrate Cocokkan Sumber – Bitrate Rendah Adaptif + Match Source – Adaptive Medium Bitrate Cocokkan Sumber – Bitrate Sedang Adaptif + Matches Cocok Dengan + Maximize or Restore Active Frame Maksimalkan atau Pulihkan Bingkai Aktif + Maximize or Restore Frame Under Cursor Maksimalkan atau Pulihkan Bingkai di Bawah Kursor + Media Browser Settings Pengaturan Peramban Media + MediaBrowser MediaBrowser + MediaCache MediaCache + Merge Captions Gabungkan Takarir + Mixer Mixer + Mosaic In Mozaik Masuk + Move All Audio Targets Down Pindahkan Semua Target Audio ke Bawah + Move All Audio Targets Up Pindahkan Semua Target Audio ke Atas + Move All Video Targets Down Pindahkan Semua Target Video ke Bawah + Move All Video Targets Up Pindahkan Semua Target Video ke Atas + Move Selection Down Pindahkan Pilihan ke Bawah + Move Selection End Pindahkan Pilihan ke Akhir + Move Selection Home Pindahkan Pilihan ke Awal + Move Selection Left Pindahkan Pilihan ke Kiri + Move Selection Page Down Pindahkan Pilihan Satu Halaman ke Bawah + Move Selection Page Up Pindahkan Pilihan Satu Halaman ke Atas + Move Selection Right Pindahkan Pilihan ke Kanan + Move Selection Up Pindahkan Pilihan ke Atas + Multi-Camera Record On/Off Toggle Alihkan Rekam Multi-Kamera Aktif/Nonaktif + Multicam Multikamera + Navigate to End of Segment Navigasi ke Akhir Segmen + Navigate to Next Line Navigasi ke Baris Berikutnya + Navigate to Next Word Navigasi ke Kata Berikutnya + Navigate to Previous Line Navigasi ke Baris Sebelumnya + Navigate to Previous Word Navigasi ke Kata Sebelumnya + Navigate to Start of Segment Navigasi ke Awal Segmen + Navigation Navigasi + Next Multi-Camera Page Halaman Multi-Kamera Berikutnya + Nudge Clip Selection Down Geser Sedikit Pilihan Klip ke Bawah + Nudge Clip Selection Left Five Frames Geser Sedikit Pilihan Klip ke Kiri Lima Bingkai + Nudge Clip Selection Left One Frame Geser Sedikit Pilihan Klip ke Kiri Satu Bingkai + Nudge Clip Selection Right Five Frames Geser Sedikit Pilihan Klip ke Kanan Lima Bingkai + Nudge Clip Selection Right One Frame Geser Sedikit Pilihan Klip ke Kanan Satu Bingkai + Nudge Clip Selection Up Geser Sedikit Pilihan Klip ke Atas + Nudge Selected Object down by five Geser Sedikit Objek Terpilih ke bawah sebanyak lima + Nudge Selected Object down by one Geser Sedikit Objek Terpilih ke bawah sebanyak satu + Nudge Selected Object to left by five Geser Sedikit Objek Terpilih ke kiri sebanyak lima + Nudge Selected Object to left by one Geser Sedikit Objek Terpilih ke kiri sebanyak satu + Nudge Selected Object to right by five Geser Sedikit Objek Terpilih ke kanan sebanyak lima + Nudge Selected Object to right by one Geser Sedikit Objek Terpilih ke kanan sebanyak satu + Nudge Selected Object up by five Geser Sedikit Objek Terpilih ke atas sebanyak lima + Nudge Selected Object up by one Geser Sedikit Objek Terpilih ke atas sebanyak satu + Nudge Volume +1dB Geser Volume +1dB + Nudge Volume +3dB Geser Volume +3dB + Nudge Volume -1dB Geser Volume -1dB + Nudge Volume -3dB Geser Volume -3dB + Numbered BMP stills (24-bit) Gambar diam BMP bernomor (24-bit) + JPEG JPEG + Targa Targa + DPX DPX + JPEG Sequence Urutan JPEG + Targa Sequence Urutan Targa + DPX Sequence Urutan DPX + Numbered JPEG stills (quality 90) Gambar diam JPEG bernomor (kualitas 90) + Numbered Targa stills (24-bit, uncompressed) Gambar diam Targa bernomor (24-bit, tanpa kompresi) + Numbered DPX stills (10-bit RGB) Gambar diam DPX bernomor (RGB 10-bit) + OpenEXR OpenEXR + OpenEXR Sequence Urutan OpenEXR + Numbered OpenEXR stills (half float, linear, uncompressed) Gambar diam OpenEXR bernomor (half float, linier, tanpa kompresi) + Numbered PNG stills at the sequence's frame size Gambar diam PNG bernomor pada ukuran bingkai sekuens + Numbered TIFF stills (RGB, 8-bit) Gambar diam TIFF bernomor (RGB, 8-bit) + Opacity 0 → 100 % over the first second Opasitas 0 → 100 % selama detik pertama + Opacity 100 → 0 % over the last second Opasitas 100 → 0 % selama detik terakhir + Open Bin Buka Bin + Open Search Buka Pencarian + PNG PNG + PNG Sequence Urutan PNG + Panel Panel + Pen Tool Alat Pena + PiP 35% Upper Right PiP 35% Kanan Atas + Picture-in-picture: 35 % scale in the upper right Gambar-dalam-gambar: skala 35 % di kanan atas + Play Around Putar di Sekitar + Play In to Out with Preroll/Postroll Putar dari Masuk ke Keluar dengan Preroll/Postroll + Play from Playhead to Out Point Putar dari Kepala Putar ke Titik Keluar + Play/Stop Putar/Berhenti + Previous Multi-Camera Page Halaman Multi-Kamera Sebelumnya + Progressive Progresif + ProjectPanel ProjectPanel + 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-bit 4:2:2), PCM 24-bit 48 kHz + QuickTime, APV 422-12 (12-bit 4:2:2), 24-bit 48 kHz PCM QuickTime, APV 422-12 (12-bit 4:2:2), PCM 24-bit 48 kHz + QuickTime, DNxHR HQ (8-bit 4:2:2), 24-bit 48 kHz PCM QuickTime, DNxHR HQ (8-bit 4:2:2), PCM 24-bit 48 kHz + QuickTime, DNxHR LB (8-bit 4:2:2), 24-bit 48 kHz PCM QuickTime, DNxHR LB (8-bit 4:2:2), PCM 24-bit 48 kHz + QuickTime, DNxHR SQ (8-bit 4:2:2), 24-bit 48 kHz PCM QuickTime, DNxHR SQ (8-bit 4:2:2), PCM 24-bit 48 kHz + QuickTime, ProRes 422 HQ, 24-bit 48 kHz PCM QuickTime, ProRes 422 HQ, PCM 24-bit 48 kHz + QuickTime, ProRes 422 LT, 24-bit 48 kHz PCM QuickTime, ProRes 422 LT, PCM 24-bit 48 kHz + QuickTime, ProRes 422 Proxy, 24-bit 48 kHz PCM QuickTime, ProRes 422 Proxy, PCM 24-bit 48 kHz + QuickTime, ProRes 4444 (4:4:4), 24-bit 48 kHz PCM QuickTime, ProRes 4444 (4:4:4), PCM 24-bit 48 kHz + QuickTime, ProRes 4444 (4:4:4) with alpha channel, 24-bit 48 kHz PCM QuickTime, ProRes 4444 (4:4:4) dengan kanal alfa, PCM 24-bit 48 kHz + QuickTime, ProRes 4444 XQ (4:4:4), 24-bit 48 kHz PCM QuickTime, ProRes 4444 XQ (4:4:4), PCM 24-bit 48 kHz + QuickTime, ProRes 422, 24-bit 48 kHz PCM QuickTime, ProRes 422, PCM 24-bit 48 kHz + Quit FilmCraft Keluar dari FilmCraft + Rate Stretch Tool Alat Regang Laju + Razor Tool Alat Silet + Rectangle Tool Alat Persegi Panjang + Remix Tool Alat Remix + Right align text Rata kanan teks + Ripple Edit Tool Alat Edit Ripple + Ripple Trim Next Edit to Playhead Pangkas Ripple Edit Berikutnya ke Kepala Putar + Ripple Trim Previous Edit to Playhead Pangkas Ripple Edit Sebelumnya ke Kepala Putar + Rolling Edit Tool Alat Edit Roll + Select Camera 1 Pilih Kamera 1 + Select Camera 2 Pilih Kamera 2 + Select Camera 3 Pilih Kamera 3 + Select Camera 4 Pilih Kamera 4 + Select Camera 5 Pilih Kamera 5 + Select Camera 6 Pilih Kamera 6 + Select Camera 7 Pilih Kamera 7 + Select Camera 8 Pilih Kamera 8 + Select Camera 9 Pilih Kamera 9 + Select Clip at Playhead Pilih Klip di Kepala Putar + Select Find Box Pilih Kotak Pencarian + Select Next Clip Pilih Klip Berikutnya + Select Next Panel Pilih Panel Berikutnya + Select Previous Clip Pilih Klip Sebelumnya + Select Previous Panel Pilih Panel Sebelumnya + Select to Next Line Pilih sampai Baris Berikutnya + Select to Next Word Pilih sampai Kata Berikutnya + Select to Previous Line Pilih sampai Baris Sebelumnya + Select to Previous Word Pilih sampai Kata Sebelumnya + Select to Segment End Pilih sampai Akhir Segmen + Select to Segment Start Pilih sampai Awal Segmen + Selection Tool Alat Seleksi + Set Comparison Reference Atur Referensi Perbandingan + Set Creative Look Atur Look Kreatif + Set Input LUT Atur LUT Input + Set Project Panel View Atur Tampilan Panel Proyek + Show Next Screen Tampilkan Layar Berikutnya + Show Previous Screen Tampilkan Layar Sebelumnya + Show Program Transcript Tampilkan Transkrip Program + Shuttle Left Shuttle ke Kiri + Shuttle Right Shuttle ke Kanan + Shuttle Slow Left Shuttle Lambat ke Kiri + Shuttle Slow Right Shuttle Lambat ke Kanan + Shuttle Stop Hentikan Shuttle + Slide Clip Selection Left Five Frames Slide Pilihan Klip ke Kiri Lima Bingkai + Slide Clip Selection Left One Frame Slide Pilihan Klip ke Kiri Satu Bingkai + Slide Clip Selection Right Five Frames Slide Pilihan Klip ke Kanan Lima Bingkai + Slide Clip Selection Right One Frame Slide Pilihan Klip ke Kanan Satu Bingkai + Slide Tool Alat Slide + Slip Clip Selection Left Five Frames Slip Pilihan Klip ke Kiri Lima Bingkai + Slip Clip Selection Left One Frame Slip Pilihan Klip ke Kiri Satu Bingkai + Slip Clip Selection Right Five Frames Slip Pilihan Klip ke Kanan Lima Bingkai + Slip Clip Selection Right One Frame Slip Pilihan Klip ke Kanan Satu Bingkai + Slip Tool Alat Slip + Social Vertical 1080×1920 Sosial Vertikal 1080×1920 + Soft Vignette Vinyet Lembut + Source In/Out Masuk/Keluar Sumber + Split Caption Pisahkan Takarir + Square Pixels (1.0) Piksel Persegi (1.0) + Stack Clips Tumpuk Klip + Start Export Queue Mulai Antrean Ekspor + Start Voice-over Recording Mulai Perekaman Sulih Suara + Step Back Five Frames Mundur Lima Bingkai + Step Back One Frame Mundur Satu Bingkai + Step Forward Five Frames Maju Lima Bingkai + Step Forward One Frame Maju Satu Bingkai + Stop Export Queue Hentikan Antrean Ekspor + Stop Multi-Camera Recording Hentikan Perekaman Multi-Kamera + Stop Voice-over Recording Hentikan Perekaman Sulih Suara + Sync Voice-over Capture Sinkronkan Perekaman Sulih Suara + TIFF TIFF + TIFF Sequence Urutan TIFF + TextPanel TextPanel + Thumbnail Size Next Ukuran Gambar Mini Berikutnya + Thumbnail Size Previous Ukuran Gambar Mini Sebelumnya + Toggle All Audio Targets Alihkan Semua Target Audio + Toggle All Source Audio Alihkan Semua Audio Sumber + Toggle All Source Video Alihkan Semua Video Sumber + Toggle All Video Targets Alihkan Semua Target Video + Toggle Audio During Scrubbing Alihkan Audio Saat Scrub + Toggle Full Screen Alihkan Layar Penuh + Toggle Mute for All Targeted Audio Tracks Alihkan Bisu untuk Semua Trek Audio Target + Toggle Solo for All Targeted Audio Tracks Alihkan Solo untuk Semua Trek Audio Target + Toggle Source/Program Monitor Focus Alihkan Fokus Monitor Sumber/Program + Toggle Target Audio 1 Alihkan Target Audio 1 + Toggle Target Audio 2 Alihkan Target Audio 2 + Toggle Target Audio 3 Alihkan Target Audio 3 + Toggle Target Audio 4 Alihkan Target Audio 4 + Toggle Target Audio 5 Alihkan Target Audio 5 + Toggle Target Audio 6 Alihkan Target Audio 6 + Toggle Target Audio 7 Alihkan Target Audio 7 + Toggle Target Audio 8 Alihkan Target Audio 8 + Toggle Target Video 1 Alihkan Target Video 1 + Toggle Target Video 2 Alihkan Target Video 2 + Toggle Target Video 3 Alihkan Target Video 3 + Toggle Target Video 4 Alihkan Target Video 4 + Toggle Target Video 5 Alihkan Target Video 5 + Toggle Target Video 6 Alihkan Target Video 6 + Toggle Target Video 7 Alihkan Target Video 7 + Toggle Target Video 8 Alihkan Target Video 8 + Toggle Track Output for All Targeted Video Tracks Alihkan Output Trek untuk Semua Trek Video Target + Toggle Trim Type Alihkan Jenis Pangkas + Toggle View Alihkan Tampilan + Track Select Backward Tool Alat Pilih Trek Mundur + Track Select Forward Tool Alat Pilih Trek Maju + Track Selected Mask Lacak Masker Terpilih + Trim Next Edit to Playhead Pangkas Edit Berikutnya ke Kepala Putar + Trim Previous Edit to Playhead Pangkas Edit Sebelumnya ke Kepala Putar + Trimming Pemangkasan + Type Tool Alat Teks + Unstack Clips Lepas Tumpukan Klip + Upper First Atas Dulu + VBR, 1 pass VBR, 1 pass + VBR, 2 pass VBR, 2 pass + CRF (constant quality) CRF (kualitas konstan) + Vimeo 1080p Full HD Vimeo 1080p Full HD + Vimeo 2160p 4K Ultra HD Vimeo 2160p 4K Ultra HD + Voice-over Record Rekam Sulih Suara + Voiceover Sulih Suara + WAV, stereo 48 kHz 16-bit PCM WAV, PCM stereo 48 kHz 16-bit + WAV, stereo 48 kHz 24-bit PCM WAV, PCM stereo 48 kHz 24-bit + Warm Tint Rona Hangat + Warm highlights, brown shadows Sorotan hangat, bayangan cokelat + Waveform Audio Waveform Audio + Waveform Audio 48 kHz 16-bit Waveform Audio 48 kHz 16-bit + Waveform Audio 48 kHz 24-bit Waveform Audio 48 kHz 24-bit + Web & Social Web & Sosial + Work Area Area Kerja + Write Automation Tulis Otomasi + YouTube 1080p Full HD YouTube 1080p Full HD + YouTube 2160p 4K Ultra HD YouTube 2160p 4K Ultra HD + Zoom Program Monitor to 100% Zoom Monitor Program ke 100% + Zoom Program Monitor to Fit Zoom Monitor Program agar Pas + Zoom Source Monitor to 100% Zoom Monitor Sumber ke 100% + Zoom Source Monitor to Fit Zoom Monitor Sumber agar Pas + Zoom Tool Alat Zoom + with: dengan: + Interface Language Bahasa Antarmuka + System Language Bahasa Sistem + Cinematic Sinematik + Film Emulation Emulasi Film + Technical Teknis + Teal Shadows, Warm Skin Bayangan Toska, Kulit Hangat + Night Exterior Eksterior Malam + Golden Dusk Senja Keemasan + Steel Blue Biru Baja + Hard Sun Matahari Terik + Faded Print Cetakan Pudar + Silver Retention Retensi Perak + Cross Processed Proses Silang + Warm Negative Negatif Hangat + Neutral Mono Mono Netral + Hard Mono Mono Keras + Sepia Tone Nada Sepia + Cold Mono Mono Dingin + Lift Shadows Angkat Bayangan + Protect Highlights Lindungi Sorotan + Half Saturation Setengah Saturasi + Boost Vibrance Tingkatkan Vibrance + Legal Range Squeeze Pemampatan Rentang Legal + A boxed label with a round pointer; move the label and the pointer follows. Label berbingkai dengan penunjuk bulat; pindahkan label dan penunjuk akan mengikuti. + A rounded tag that fits its text, with a status dot. Tag membulat yang menyesuaikan teksnya, dengan titik status. + A title on a rounded card with a soft shadow. Judul pada kartu membulat dengan bayangan lembut. + Accent Color Warna Aksen + Bar Color Warna Bilah + Box Color Warna Kotak + Box Opacity Opasitas Kotak + Callout – Pointer Callout – Penunjuk + Callout – Tag Callout – Tag + Callouts Callout + Credits Kredit + Dot Color Warna Titik + Heading Judul Utama + Large centred title, accent rule and subtitle. Judul besar di tengah, garis aksen, dan subjudul. + Lower Third – Rule Lower Third – Garis + Lower Third – Slab Lower Third – Lempeng + Lower Thirds Lower Third + Name Color Warna Nama + Name Size Ukuran Nama + Name and role on a coloured slab that widens with the name. Nama dan peran pada lempeng berwarna yang melebar mengikuti nama. + Name over a thin rule that always matches the name's width. Nama di atas garis tipis yang selalu menyamai lebar nama. + Pointer Color Warna Penunjuk + Role Peran + Rule Color Warna Garis + Show Dot Tampilkan Titik + Show Role Tampilkan Peran + Slab Color Warna Lempeng + Tag Color Warna Tag + Title Color Warna Judul + Title Size Ukuran Judul + Title – Boxed Judul – Berbingkai + Title – Centered Judul – Di Tengah + Titles Judul + Select an item to preview it Pilih item untuk melihat pratinjaunya + End Credits Kredit Penutup + End Credits – Roll Kredit Penutup – Gulung + Ticker – Crawl Ticker – Merayap + Centred credits that roll up from below the frame and leave at the top. Kredit di tengah yang bergulung naik dari bawah bingkai dan keluar di atas. + A news-style line that crawls in from the right and leaves at the left. Baris bergaya berita yang merayap masuk dari kanan dan keluar di kiri. + out keluar + in masuk + The language changed but could not be saved: {e} Bahasa telah diubah tetapi tidak dapat disimpan: {e} + no Chinese font is installed on this system; the interface stays in English tidak ada font Tionghoa yang terpasang di sistem ini; antarmuka tetap dalam bahasa Inggris + no Japanese font is installed on this system; the interface stays in English tidak ada font Jepang yang terpasang di sistem ini; antarmuka tetap dalam bahasa Inggris + Show Tampilkan + Sort Urutkan + Reverse the order Balik urutan + Grab bag Campur aduk + Table Tabel + No contributor data was built into this copy. Tidak ada data kontributor yang disertakan dalam salinan ini. + No model credits were built into this copy. Tidak ada kredit model yang disertakan dalam salinan ini. + Company Perusahaan + Model Model + Version Versi + Commits Commit + % of all commits % dari semua commit + Lines +/− Baris +/− + Audio output failed: playing without sound (check Settings ▸ Audio Hardware) Output audio gagal: memutar tanpa suara (periksa Pengaturan ▸ Perangkat Keras Audio) + Audio output is not responding: playing without sound (check Settings ▸ Audio Hardware) Output audio tidak merespons: memutar tanpa suara (periksa Pengaturan ▸ Perangkat Keras Audio) + Clip Speed / Duration Kecepatan / Durasi Klip + Speed: Kecepatan: + Reverse Speed Balik Kecepatan + Ripple Edit, Shifting Trailing Clips Edit Ripple, Geser Klip Berikutnya + About Tentang + Contributors Kontributor + Models Model + Main (8-bit) Main (8-bit) + Main 10 (10-bit, HDR) Main 10 (10-bit, HDR) + Main (8-bit; Main 10 for HDR sequences) Main (8-bit; Main 10 untuk sekuens HDR) + Encoder Enkoder + Hardware (H.265 has no software encoder) Perangkat keras (H.265 tidak memiliki enkoder perangkat lunak) + Hardware (AV1 has no software encoder) Perangkat keras (AV1 tidak memiliki enkoder perangkat lunak) + Text Properties Properti Teks + Text Box Height Tinggi Kotak Teks + Paragraph Text Teks Paragraf + Point Text Teks Titik + Text Layer Type Jenis Lapisan Teks + Text Styling Gaya Teks + New Color Matte Matte Warna Baru + Color Matte Color Warna Matte Warna + Color: Warna: + · {time} left · sisa {time} + Discard take Buang take + Voice-over take discarded Take sulih suara dibuang + Binary assets added Aset biner ditambahkan + Binary assets removed Aset biner dihapus + Deselect All Project Items Batalkan Pilihan Semua Item Proyek + Display name Nama tampilan + First commit Commit pertama + H.265 (HEVC) H.265 (HEVC) + Last commit Commit terakhir + Line delta Delta baris + Lines added Baris ditambahkan + Lines deleted Baris dihapus + Merged PRs PR yang digabung + Name (A–Z) Nama (A–Z) + Real name Nama asli + Select All Project Items Pilih Semua Item Proyek + Username Nama pengguna + Vertical Type Tool Alat Teks Vertikal + Go to previous keyframe Ke bingkai kunci sebelumnya + Go to next keyframe Ke bingkai kunci berikutnya + Add keyframe Tambah bingkai kunci + Remove keyframe Hapus bingkai kunci + Turn on animation and add keyframe Aktifkan animasi dan tambah bingkai kunci + Click to select, or click in empty space and drag to marquee select. Use Shift, Alt, and Ctrl for other options. Klik untuk memilih, atau klik di ruang kosong lalu seret untuk memilih dengan bingkai. Gunakan Shift, Alt, dan Ctrl untuk opsi lain. + Click to zoom in; Alt-click to zoom out. Klik untuk memperbesar; Alt-klik untuk memperkecil. + Drag: move · Alt-click: smooth/corner · Ctrl-click: delete Seret: pindahkan · Alt-klik: halus/sudut · Ctrl-klik: hapus + Include Alpha Channel Sertakan Kanal Alfa + Sequence Settings Pengaturan Sekuens + Color Management Manajemen Warna + VR Properties Properti VR + Editing Mode: Mode Penyuntingan: + Timebase: Basis Waktu: + Frame Size: Ukuran Bingkai: + Pixel Aspect Ratio: Rasio Aspek Piksel: + Fields: Field: + Channel Format: Format Kanal: + Number of Channels: Jumlah Kanal: + Preview File Format: Format Berkas Pratinjau: + Working Color Space: Ruang Warna Kerja: + Projection: Proyeksi: + Layout: Tata Letak: + Captured View: Tampilan yang Direkam: + Video Previews Pratinjau Video + No Fields (Progressive Scan) Tanpa Field (Pindai Progresif) + Custom ({ratio}) Kustom ({ratio}) + {rate} frames/second {rate} bingkai/detik + {rate} fps Drop-Frame Timecode Kode Waktu Drop-Frame {rate} fps + {rate} fps Non Drop-Frame Timecode Kode Waktu Non Drop-Frame {rate} fps + {rate} fps Timecode Kode Waktu {rate} fps + horizontal horizontal + vertical vertikal + Scale motion effects proportionally when changing frame size Skalakan efek gerak secara proporsional saat mengubah ukuran bingkai + Maximum Bit Depth Kedalaman Bit Maksimum + Maximum Render Quality Kualitas Render Maksimum + Composite in Linear Color Komposit dalam Warna Linier + Auto Tone Map Media (HDR and log into SDR) Pemetaan Nada Otomatis Media (HDR dan log ke SDR) + Configure… Konfigurasi… + Not supported in FilmCraft yet Belum didukung di FilmCraft + FilmCraft always composites in linear light FilmCraft selalu mengomposit dalam cahaya linier + Loop marked range Ulangi rentang yang ditandai + Play Source Putar Sumber + Source Play/Stop Putar/Berhenti Sumber + Stop Source Hentikan Sumber + New Sequence Sekuens Baru + Tracks Trek + tracks trek + Clear Transition Hapus Transisi + Set Transition Duration Atur Durasi Transisi + Active Aktif + Appearance Mode Mode Penampilan + Dark Theme Tema Gelap + Light Theme Tema Terang + Next Appearance Mode Mode Penampilan Berikutnya + Sync with system Sinkronkan dengan sistem + UI Scale Skala UI + Auto (system) Otomatis (sistem) + In Masuk + Out Keluar + Export Markers as CSV… Ekspor Penanda sebagai CSV… + Fill Type Jenis Isian + Gradient Angle Sudut Gradien + Linear Gradient Gradien Linier + Solid Solid + GPU Rendering Rendering GPU + Composite on the GPU when available Komposit di GPU bila tersedia + Text to Speech Teks ke Ucapan + Edit Narration Edit Narasi + Project Notes Catatan Proyek + Get Project Notes Ambil Catatan Proyek + Edit Project Notes Edit Catatan Proyek + Notes for this project: to-dos, instructions, frame numbers… Saved with the project. Catatan untuk proyek ini: daftar tugas, instruksi, nomor bingkai… Disimpan bersama proyek. + Use Effect Shutter Angle Gunakan Sudut Rana Efek + Settings Preferensi diff --git a/crates/ui-egui/src/i18n/id.tsv.attribution b/crates/ui-egui/src/i18n/id.tsv.attribution new file mode 100644 index 000000000..ca37e39c2 --- /dev/null +++ b/crates/ui-egui/src/i18n/id.tsv.attribution @@ -0,0 +1,8 @@ +asset: id.tsv +title: Indonesian 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-11 by vangel76 +notes: Original English-to-Indonesian 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 7225b5c98..387cd5570 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -45,6 +45,7 @@ pub enum Language { De, Ru, Fr, + Id, } static JAPANESE: OnceLock = OnceLock::new(); @@ -55,9 +56,10 @@ static CHINESE: OnceLock = OnceLock::new(); static GERMAN: OnceLock = OnceLock::new(); static RUSSIAN: OnceLock = OnceLock::new(); static FRENCH: OnceLock = OnceLock::new(); +static INDONESIAN: OnceLock = OnceLock::new(); impl Language { - pub const ALL: [Self; 9] = [Self::En, Self::Ja, Self::Es, Self::PtBr, Self::Uk, Self::ZhCn, Self::De, Self::Ru, Self::Fr]; + pub const ALL: [Self; 10] = [Self::En, Self::Ja, Self::Es, Self::PtBr, Self::Uk, Self::ZhCn, Self::De, Self::Ru, Self::Fr, Self::Id]; pub fn name(self) -> &'static str { match self { @@ -70,6 +72,7 @@ impl Language { Self::De => "Deutsch", Self::Ru => "Русский", Self::Fr => "Français", + Self::Id => "Bahasa Indonesia", } } @@ -84,6 +87,7 @@ impl Language { "de" => Some(Self::De), "ru" => Some(Self::Ru), "fr" => Some(Self::Fr), + "id" => Some(Self::Id), _ => None, } } @@ -92,11 +96,12 @@ 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; /// 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 + /// the French one; any Indonesian (`id-ID`, `id_ID.UTF-8`, and the legacy `in-ID` that older Java + /// and Android runtimes report) the Indonesian 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); 8] = [ + const PRIMARY: [(&str, Language); 10] = [ ("en", Language::En), ("ja", Language::Ja), ("es", Language::Es), @@ -105,6 +110,8 @@ impl Language { ("de", Language::De), ("ru", Language::Ru), ("fr", Language::Fr), + ("id", Language::Id), + ("in", Language::Id), ]; tags.iter() .find_map(|tag| { @@ -134,6 +141,7 @@ impl Language { Self::De => "de", Self::Ru => "ru", Self::Fr => "fr", + Self::Id => "id", } } @@ -149,6 +157,7 @@ impl Language { 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")))), + Self::Id => Some(INDONESIAN.get_or_init(|| Catalog::parse(include_str!("id.tsv")))), } } @@ -319,6 +328,7 @@ mod tests { ("de", include_str!("de.tsv")), ("ru", include_str!("ru.tsv")), ("fr", include_str!("fr.tsv")), + ("id", include_str!("id.tsv")), ] { let (entries, errors) = catalog::parse_entries(text); assert!(errors.is_empty(), "{code}: {errors:?}"); @@ -366,6 +376,13 @@ mod tests { assert_eq!(Language::Fr.tr("An untranslated label"), "An untranslated label"); assert_eq!(Language::parse("fr"), Some(Language::Fr)); assert_eq!(Language::parse("fr-FR"), None, "the preference stores the bare code"); + assert_eq!(Language::Id.name(), "Bahasa Indonesia"); + assert_eq!(Language::Id.tr("File"), "Berkas"); + assert_eq!(Language::Id.tr("Save As…"), "Simpan Sebagai…"); + assert_eq!(Language::Id.tr("Liburan keluarga.mp4"), "Liburan keluarga.mp4"); + assert_eq!(Language::Id.tr("An untranslated label"), "An untranslated label"); + assert_eq!(Language::parse("id"), Some(Language::Id)); + assert_eq!(Language::parse("id-ID"), None, "the preference stores the bare code"); for l in Language::ALL { assert_eq!(Language::parse(l.code()), Some(l)); let json = serde_json::to_string(&l).unwrap(); @@ -834,6 +851,24 @@ 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::Fr); + let result = crate::menus::invoke(&mut app, &ctx, "app.language.indonesian", serde_json::json!({})).unwrap(); + assert_eq!(result, serde_json::json!("id")); + assert_eq!((app.ui.language, current()), (Language::Id, Language::Id)); + assert_eq!(app.session.prefs.general.interface_language, "id"); + 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.indonesian"), "{}", 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("Simpan Sebagai…")); + assert_eq!(label("app.language.french"), Some("Français")); + assert_eq!(label("app.language.indonesian"), Some("Bahasa Indonesia")); + let saved = serde_json::to_string(&app.ui).unwrap(); + assert!(saved.contains("\"language\":\"id\""), "{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::Id); crate::menus::invoke(&mut app, &ctx, "app.language.english", serde_json::json!({})).unwrap(); assert_eq!(app.ui.language, Language::En); set_current(Language::En); @@ -865,6 +900,13 @@ mod tests { 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}"); } + for tag in ["id", "ID", "id-ID", "id_ID.UTF-8", "id_ID", "in", "in-ID", "in_ID.UTF-8"] { + assert_eq!(l(&[tag]), Language::Id, "{tag}"); + } + assert_eq!(l(&["jv-ID", "ms-MY", "id-ID", "en"]), Language::Id, "Javanese and Malay have no catalog"); + for none in [&["ind"][..], &["i"], &["-id"], &["ms"], &["ms-ID"], &["jv"]] { + assert_eq!(l(none), Language::En, "{none:?}"); + } 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); @@ -1000,6 +1042,41 @@ mod tests { } } + /// Indonesian covers the whole interface (the Spanish catalog's entries and the native macOS + /// Settings menu), but, like Japanese and French, the tests require only the core menus (the + /// menus-only Ukrainian catalog, the top-level menus and the native macOS Settings menu), so + /// strings added later show in English until they are translated. Every entry must still have a + /// Spanish counterpart: a renamed or removed source would otherwise leave a dead entry. + #[test] + fn indonesian_covers_the_menus_and_has_no_dead_entries() { + let (entries, errors) = catalog::parse_entries(include_str!("id.tsv")); + assert!(errors.is_empty(), "{errors:?}"); + for (ctx, en, _) in &entries { + assert!(ctx.is_empty() && (Language::Es.has(en) || en == "Settings"), "Indonesian entry without a Spanish one: {ctx:?} {en:?}"); + } + let (core, _) = catalog::parse_entries(include_str!("uk.tsv")); + for (_, en, _) in core { + assert!(Language::Id.has(&en), "missing Indonesian menu label: {en}"); + } + for en in crate::menus::MENUS.into_iter().chain(["Audio", "Settings"]) { + assert!(Language::Id.has(en), "untranslated Indonesian menu: {en}"); + } + // beyond the menus: panels, effects and their parameters, settings, status messages + for (en, id) in [ + ("Settings", "Preferensi"), + ("Gaussian Blur", "Buram Gaussian"), + ("Audio Track Mixer", "Mixer Trek Audio"), + ("Sequence", "Sekuens"), + ("Playhead", "Kepala Putar"), + ] { + assert_eq!(Language::Id.tr(en), id); + } + set_current(Language::Id); + assert!(matches_query("Gaussian Blur", "buram")); + assert!(matches_query("Gaussian Blur", "gaussian")); + set_current(Language::En); + } + /// 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(); @@ -1043,6 +1120,16 @@ mod tests { assert_bundled_fonts_cover(alphabet.chars().chain(Language::Fr.name().chars()).chain(letters)); } + #[test] + fn indonesian_renders_with_bundled_fonts() { + let (entries, _) = catalog::parse_entries(include_str!("id.tsv")); + // Indonesian is written in basic Latin letters; any other letter an entry uses, and its + // curly quotes, dashes and ellipses, must still render (symbols such as ✔ come from the + // English sources) + let letters = entries.iter().flat_map(|(_, _, id)| id.chars()).filter(|ch| ch.is_alphabetic() || "“”‘’–—…".contains(*ch)); + assert_bundled_fonts_cover(Language::Id.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 1cca0b691..e2df819a9 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -31,6 +31,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ 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!("app.language.indonesian", "Bahasa Indonesia", ["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), @@ -186,6 +187,7 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: | "app.language.german" | "app.language.russian" | "app.language.french" + | "app.language.indonesian" ) { // 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) { @@ -204,6 +206,7 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: "app.language.german" => crate::i18n::Language::De, "app.language.russian" => crate::i18n::Language::Ru, "app.language.french" => crate::i18n::Language::Fr, + "app.language.indonesian" => crate::i18n::Language::Id, _ => crate::i18n::Language::En, }; // The preference is updated in memory before it is written, so a failed write (read-only @@ -629,6 +632,7 @@ pub fn menu_items(app: &FilmcraftApp) -> Vec { "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), + "app.language.indonesian" => it.checked = Some(app.ui.language == crate::i18n::Language::Id), _ => {} } if it.id.starts_with("view.") { diff --git a/docs/gaps.md b/docs/gaps.md index 564541067..d6b8e9815 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -204,9 +204,12 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** - **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), - Indonesian, German beyond menus (#490), Korean (#508; needs Hangul fonts in craft-fonts), + 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. +- **Done:** Indonesian (#867): `id.tsv` has every entry of the Spanish catalog (~100%), in Edit ▸ + Language, Settings ▸ General and the native macOS menu; System Language picks it for `id-*` (and + legacy `in-*`) locales. Native-speaker review welcome. - **Estimate:** 150–230 h. Doc: [localization-parity.md](localization-parity.md). ## G15. Interchange never validated in Avid / Pro Tools; slow XML import diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 6124d1c7a..2e3d17459 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -10,7 +10,7 @@ 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, @@ -44,7 +44,7 @@ whole interface, one PR) at ~4–8 h per language for the catalog plus tests. | Arabic | ar | 0 (0%) | — | **no RTL layout, no shaping in the interface**; titles broken (#395) | — | none | 40–70 h | | French | fr | 3,526 (same entries as Spanish, plus the macOS Settings item) | as Spanish | Latin (bundled fonts) | not recorded; welcome | partial (#498) | review | | Portuguese (Brazil) | pt-br | 348 (9.9%) | menus only | Latin | not recorded | menus only | 5–9 h + review | -| Indonesian | id | 0 (0%) | — | Latin | — | none | 6–10 h + review | +| Indonesian | id | 3,541 (same entries as Spanish, plus the macOS Settings item); must cover the core menus, no entry without a Spanish one | as Spanish | Latin (bundled fonts); System Language also maps the legacy `in` code | not recorded; welcome | partial (#867) | review | | 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 | From 5ed23c942464f7bec93e776a2e262ef82776a877 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:04:56 +0200 Subject: [PATCH 164/260] monitors: show the unreadable slate for persistently failing frames A damaged file whose frames fail to decode or read again and again rendered black with no explanation. In the monitors only, after 3 consecutive Decode/Io failures of the same item its failing frames show the existing MEDIA UNREADABLE slate; a decoded frame clears it. Pending (still-loading) reads never count, and exports never see it. --- crates/engine/src/media_pool.rs | 181 ++++++++++++++++++++++++++++++++ crates/ui-egui/src/frames.rs | 2 +- 2 files changed, 182 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/media_pool.rs b/crates/engine/src/media_pool.rs index ac849710c..b14ea959a 100644 --- a/crates/engine/src/media_pool.rs +++ b/crates/engine/src/media_pool.rs @@ -38,6 +38,8 @@ pub struct MediaPool { proxies: RwLock>, offline: Arc>>, use_proxies: AtomicBool, + /// Per item: video frames that failed in a row in the monitors (see [`Self::monitor_source`]). + failures: Arc>>, /// Openers tried before the built-in ones (MP4/MOV + codecs register here). pub openers: RwLock>, } @@ -50,6 +52,7 @@ impl Default for MediaPool { proxies: RwLock::new(HashMap::new()), offline: Arc::new(RwLock::new(HashMap::new())), use_proxies: AtomicBool::new(false), + failures: Arc::default(), openers: RwLock::new(filmcraft_codecs::openers()), } } @@ -183,6 +186,19 @@ impl MediaPool { self.use_proxies.store(on, Ordering::Relaxed); } + /// `src` (item `item`) as the monitors show it: once [`DAMAGED_AFTER`] of its video frames in a + /// row fail to decode or read (a damaged file), its failing frames show the unreadable slate + /// instead of nothing, until a frame decodes again (#626, follow-up to #762). Only for preview: + /// exports and other renders use the pool's sources directly, so they never contain the slate. + pub fn monitor_source(&self, item: ItemId, src: SharedSource) -> SharedSource { + Arc::new(DamagedAsSlate { + slate: SlateSource::new(src.info().clone(), &src.info().name, OfflineReason::Unreadable), + inner: src, + item, + failures: self.failures.clone(), + }) + } + /// Why an item last rendered offline (None = online or not opened yet). pub fn offline_status(&self, item: ItemId) -> Option { self.offline.read().unwrap_or_else(|e| e.into_inner()).get(&item).cloned() @@ -419,6 +435,54 @@ impl MediaSource for UnsupportedAsSlate { } } +/// Consecutive failed video frames after which a monitor treats a source as damaged. A single +/// failure (one bad slice, a read hiccup) and the decoder's retry of it stay as they were; three +/// in a row (an eighth of a second at 24 fps) mean the file itself is damaged. +pub const DAMAGED_AFTER: u32 = 3; + +/// See [`MediaPool::monitor_source`]. Counts `Decode` / `Io` failures per item; a successful frame +/// resets the count. Data still on its way (the web build's pending reads, see +/// [`filmcraft_media::pending`]) neither counts nor resets, and cancellation, absent video and +/// unsupported frames (already the slate, #762) pass through. +struct DamagedAsSlate { + inner: SharedSource, + slate: SlateSource, + item: ItemId, + failures: Arc>>, +} + +impl MediaSource for DamagedAsSlate { + fn info(&self) -> &MediaInfo { + self.inner.info() + } + fn video_frame(&self, req: FrameRequest) -> filmcraft_media::Result> { + let r = self.inner.video_frame(req); + let mut failures = self.failures.lock().unwrap_or_else(|e| e.into_inner()); + match r { + Ok(f) => { + failures.remove(&self.item); + Ok(f) + } + Err(MediaError::Decode(_) | MediaError::Io(_)) if !filmcraft_media::pending::is_set() => { + let n = failures.entry(self.item).or_insert(0); + *n = n.saturating_add(1); + if *n >= DAMAGED_AFTER { + drop(failures); + return self.slate.video_frame(req); + } + r + } + 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. @@ -592,6 +656,123 @@ mod tests { assert!(pool.offline_status(item).is_none()); } + /// A source whose video decodes (`OK`) or fails as its mode says, switchable at run time. + struct Flaky(MediaInfo, std::sync::atomic::AtomicU8); + const OK: u8 = 0; + const DAMAGED: u8 = 1; + const IO: u8 = 2; + const LOADING: u8 = 3; + const CANCELLED: u8 = 4; + const NO_VIDEO: u8 = 5; + impl Flaky { + fn set(&self, mode: u8) { + self.1.store(mode, Ordering::Relaxed); + } + } + impl MediaSource for Flaky { + fn info(&self) -> &MediaInfo { + &self.0 + } + fn video_frame(&self, _: FrameRequest) -> filmcraft_media::Result> { + Err(match self.1.load(Ordering::Relaxed) { + OK => return Ok(Arc::new(VideoFrame::rgba8(2, 2, vec![7; 16]))), + DAMAGED => MediaError::Decode("damaged slice".into()), + IO => MediaError::Io("short read".into()), + LOADING => { + // as the web build's WebCodecs / Blob sources do + filmcraft_media::pending::mark(); + MediaError::Decode("still decoding".into()) + } + CANCELLED => MediaError::Cancelled, + _ => MediaError::NoStream("video"), + }) + } + fn audio(&self, _start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result { + Ok(AudioBuffer::silence(sample_rate, 1, frames)) + } + } + + fn flaky() -> (MediaPool, ItemId, Arc) { + let info = GeneratorSource::new(filmcraft_media::Generator::ColorMatte { color: [0.0; 4] }, 16, 8, Default::default(), filmcraft_time::Tick(100)) + .info() + .clone(); + (MediaPool::default(), ItemId(1), Arc::new(Flaky(info, Default::default()))) + } + + fn frame(src: &SharedSource) -> filmcraft_media::Result> { + src.video_frame(FrameRequest { time: filmcraft_time::Tick(0), scale: 1.0 }) + } + + fn is_slate(r: &filmcraft_media::Result>, name: &str) -> bool { + r.as_ref().is_ok_and(|f| f.to_rgba8() == filmcraft_render::offline::slate_rgba8(16, 8, name, OfflineReason::Unreadable)) + } + + /// #626 follow-up to #762: a damaged file (frames failing again and again) shows the + /// unreadable slate in the monitors; one failure does not, and a decoded frame clears it. + #[test] + fn monitors_show_the_slate_for_persistently_failing_frames_only() { + let (pool, item, src) = flaky(); + let name = src.info().name.clone(); + let mon = pool.monitor_source(item, src.clone()); + for mode in [DAMAGED, IO] { + src.set(mode); + for _ in 1..DAMAGED_AFTER { + assert!(frame(&mon).is_err(), "a transient failure stays as it was"); + } + assert!(is_slate(&frame(&mon), &name), "persistent failure shows the slate"); + assert!(is_slate(&frame(&mon), &name)); + // recovery clears it, and one failure after that is transient again + src.set(OK); + assert_eq!(frame(&mon).unwrap().to_rgba8(), vec![7; 16]); + src.set(DAMAGED); + assert!(frame(&mon).is_err()); + src.set(OK); + assert!(frame(&mon).is_ok()); + } + // cancellation and absent video never become the slate + for mode in [CANCELLED, NO_VIDEO] { + src.set(mode); + for _ in 0..DAMAGED_AFTER * 2 { + assert!(frame(&mon).is_err()); + } + } + } + + /// Pictures still on their way (the web build's pending reads) never count as damage. + #[test] + fn still_decoding_frames_never_show_the_slate() { + let (pool, item, src) = flaky(); + let mon = pool.monitor_source(item, src.clone()); + src.set(LOADING); + for _ in 0..DAMAGED_AFTER * 4 { + assert!(frame(&mon).is_err()); + assert!(filmcraft_media::pending::take()); + } + // and they do not interrupt a run of real failures either way: a following damaged frame + // is still only the first + src.set(DAMAGED); + assert!(frame(&mon).is_err()); + } + + /// Exports read the pool's own sources: a damaged file never burns the slate into them, + /// however often the monitors have shown it. + #[test] + fn export_sources_never_show_the_damaged_slate() { + let (pool, item, src) = flaky(); + let name = src.info().name.clone(); + pool.insert(item, src.clone()); + let mon = pool.monitor_source(item, pool.cached(item).unwrap()); + src.set(DAMAGED); + for _ in 0..DAMAGED_AFTER { + let _ = frame(&mon); + } + assert!(is_slate(&frame(&mon), &name)); + let export = pool.cached(item).unwrap(); + for _ in 0..DAMAGED_AFTER * 2 { + assert!(matches!(frame(&export), Err(MediaError::Decode(_)))); + } + } + /// #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] diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index c8db19cdd..9a6f52b76 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -263,7 +263,7 @@ struct JobProvider { impl filmcraft_render::SourceProvider for JobProvider { fn source(&self, item: ItemId) -> Option { - let s = self.inner.source(item)?; + let s = self.inner.pool.monitor_source(item, self.inner.source(item)?); Some(Arc::new(TimedSource(s))) } } From 1e6f42eda2d818198332f119400df8540ce4dc27 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 03:11:27 +0200 Subject: [PATCH 165/260] #847: AV1 decode runs deblocking, CDEF and loop restoration in parallel row bands Problem: the AV1 in-loop filters ran on one thread per frame (postfilter::apply had an unused pool). On a single-tile stream they are a quarter to a third of a frame, and since references are waited for as whole frames, that serial tail sat on the critical path of every frame that references it. Fix: the three filters run in bands of 64 luma rows on the decoder's existing rayon pool, one join per filter per frame. CDEF and loop restoration write only their own rows of a separate output. The loop filter's edges never touch the same sample, and only a band's top horizontal edges reach into the band above: each band refilters the vertical edges of a copy of the 8 rows above it, runs those edges against the copy, and the samples they changed are written back. With one thread the code path is the old one. Output stays bit-exact (1 vs 16 threads vs libdav1d; new 4:2:2 / 4:4:4 libaom fixtures). perf.rs gains a low-delay 1080p and a single-tile 2160p fixture and the all-core stage breakdown. 16 threads: 1080p GOP 1.16-1.30x, low delay 1.28x, 2160p 1.14x. Co-Authored-By: Claude Opus 5.5 --- crates/av1/README.md | 9 +- crates/av1/src/cdef.rs | 53 ++++++---- crates/av1/src/frame.rs | 89 +++++++++++++++++ crates/av1/src/postfilter.rs | 165 ++++++++++++++++++++++++++----- crates/av1/src/restoration.rs | 90 +++++++++-------- crates/av1/tests/common/mod.rs | 18 +++- crates/av1/tests/oracle_inter.rs | 16 +++ crates/av1/tests/perf.rs | 47 ++++++--- docs/gaps.md | 8 +- docs/performance.md | 43 ++++++++ 10 files changed, 429 insertions(+), 109 deletions(-) diff --git a/crates/av1/README.md b/crates/av1/README.md index 5d4d5c50f..354484158 100644 --- a/crates/av1/README.md +++ b/crates/av1/README.md @@ -70,12 +70,15 @@ with libdav1d: **bit-exact** on all 6 fixtures. wasm32, or without the default `threads` feature, everything runs on the calling thread). Headers are parsed in order; each frame is then decoded by a frame worker once the reference frames it reads are finished, so independent frames (hierarchical GOP layers) decode concurrently; tiles of a frame decode in parallel into -private buffers that are merged afterwards. Results never depend on scheduling (the oracle tests -compare 1 thread, many threads and libdav1d). With frame threads, pictures may be returned by a +private buffers that are merged afterwards; the in-loop filters (deblocking, CDEF, loop +restoration) each run in parallel bands of 64 rows (`postfilter.rs` explains why the bands are +independent). Results never depend on scheduling (the oracle tests compare 1 thread, many threads +and libdav1d). With frame threads, pictures may be returned by a later `decode_pts` call or by `flush`; each carries the `pts` of the temporal unit that showed it. `tests/perf.rs` (ignored) prints single-threaded fps (thread CPU time), per-stage ms/frame -(`Decoder::stats()`) and all-core fps for 1080p fixtures. The hot loops (sub-pixel filters, +(`Decoder::stats()`) and all-core fps with its per-stage ms/frame for 1080p fixtures (key frames, +GOPs, tiles, low delay) and a single-tile 2160p GOP; `AV1_PERF_ONLY=a,b` picks fixtures. The hot loops (sub-pixel filters, CDEF, inverse transforms, deblocking, entropy decoding) are written for auto-vectorisation. Measured 2026-10-01 on a 14-core Apple Silicon Mac (release build, SVT-AV1 preset 8 1080p diff --git a/crates/av1/src/cdef.rs b/crates/av1/src/cdef.rs index 13efa75db..4038a0f02 100644 --- a/crates/av1/src/cdef.rs +++ b/crates/av1/src/cdef.rs @@ -1,35 +1,45 @@ //! CDEF (7.15). -use crate::frame::{FrameBuf, Plane}; +use crate::frame::{FrameBuf, RowBand}; +use crate::par::Pool; +use crate::postfilter::bands; use crate::spec_tables::*; use crate::state::FrameState; /// Run CDEF on `fs.cur`; returns the CdefFrame, or None when no block is filtered (CdefFrame -/// equals CurrFrame). -pub(crate) fn apply(fs: &FrameState) -> Option { +/// equals CurrFrame). Bands of 64 luma rows (whole 8x8 blocks) are filtered in parallel: every +/// block reads only CurrFrame and writes only its own samples. +pub(crate) fn apply(fs: &FrameState, pool: &Pool) -> Option { let fh = &fs.fh; if fh.coded_lossless || fh.allow_intrabc || !fs.seq.enable_cdef { return None; } let (mi_rows, mi_cols) = (fh.mi_rows as usize, fh.mi_cols as usize); let mut out = fs.cur.clone(); - let mut any = false; - let mut r = 0; - while r < mi_rows { - let mut c = 0; - while c < mi_cols { - let idx = fs.cdef_idx(r & !15, c & !15); - if idx >= 0 && cdef_block(fs, &mut out, r, c, idx as usize) { - any = true; + let ranges = bands(pool, mi_rows * 4); + let mut planes = out.row_bands(&ranges); + let any = pool.map_mut(&mut planes, |b, band| { + let (start, end) = ranges[b]; + let mut any = false; + let mut r = start / 4; + while r < mi_rows.min(end / 4) { + let mut c = 0; + while c < mi_cols { + let idx = fs.cdef_idx(r & !15, c & !15); + if idx >= 0 && cdef_block(fs, band, r, c, idx as usize) { + any = true; + } + c += 2; } - c += 2; + r += 2; } - r += 2; - } - any.then_some(out) + any + }); + drop(planes); + any.contains(&true).then_some(out) } -fn cdef_block(fs: &FrameState, out: &mut FrameBuf, r: usize, c: usize, idx: usize) -> bool { +fn cdef_block(fs: &FrameState, out: &mut [RowBand; 3], r: usize, c: usize, idx: usize) -> bool { let mi = &fs.mi; let sk = |rr: usize, cc: usize| -> bool { if rr < mi.rows && cc < mi.cols { mi.skip[mi.idx(rr, cc)] } else { true } }; let skip = sk(r, c) && sk(r + 1, c) && sk(r, c + 1) && sk(r + 1, c + 1); @@ -125,7 +135,7 @@ fn constrain(diff: i32, threshold: i32, adj: u32) -> i32 { } #[allow(clippy::too_many_arguments)] -fn filter(fs: &FrameState, out: &mut FrameBuf, plane: usize, r: usize, c: usize, pri: i32, sec: i32, damping: i32, dir: usize) { +fn filter(fs: &FrameState, out: &mut [RowBand; 3], plane: usize, r: usize, c: usize, pri: i32, sec: i32, damping: i32, dir: usize) { if pri == 0 && sec == 0 { // Every tap contributes 0 and the clamp to [min, max] keeps x: CdefFrame = CurrFrame. return; @@ -169,7 +179,7 @@ fn filter(fs: &FrameState, out: &mut FrameBuf, plane: usize, r: usize, c: usize, let sec_taps = [CDEF_SEC_TAPS[pri_tap][0] as i32, CDEF_SEC_TAPS[pri_tap][1] as i32]; let adj = |t: i32| if t == 0 { 0 } else { (damping - (31 - (t as u32).leading_zeros()) as i32).max(0) as u32 }; let (pri_adj, sec_adj) = (adj(pri), adj(sec)); - let dst = &mut out.planes[plane]; + let dst = &mut out[plane]; if all_avail { // Every tap is available: whole rows at a time (constrain() with threshold 0 is 0). let mut taps = [(0isize, 0i32, 0i32, 0u32); 12]; @@ -230,7 +240,12 @@ fn filter(fs: &FrameState, out: &mut FrameBuf, plane: usize, r: usize, c: usize, /// CDEF of a block whose taps are all available, `W` samples per row. Samples (at most 12 /// bits), differences and the weighted tap sum all fit in i16, which doubles the SIMD width. #[inline(always)] -fn filter_rows(buf: &[i32; PAD_STRIDE * PAD_STRIDE], taps: &[(isize, i32, i32, u32); 12], dst: &mut Plane, (y0, x0, h): (usize, usize, usize)) { +fn filter_rows( + buf: &[i32; PAD_STRIDE * PAD_STRIDE], + taps: &[(isize, i32, i32, u32); 12], + dst: &mut RowBand, + (y0, x0, h): (usize, usize, usize), +) { let mut b16 = [0i16; PAD_STRIDE * PAD_STRIDE]; for (d, &s) in b16.iter_mut().zip(buf.iter()) { *d = s as i16; diff --git a/crates/av1/src/frame.rs b/crates/av1/src/frame.rs index 3cf4b9f0b..a0e242345 100644 --- a/crates/av1/src/frame.rs +++ b/crates/av1/src/frame.rs @@ -106,6 +106,48 @@ impl Plane { dst.row_from_mut(self.oy + y, self.ox)[..self.stride].copy_from_slice(s); } } + + /// Disjoint mutable bands of whole rows, for filtering in parallel: one per `[start, end)` + /// range of rows (relative to `oy`; ascending and not overlapping, else the later band is + /// cut to what is left). Ranges are clipped to the stored rows. + pub fn row_bands(&mut self, ranges: &[(usize, usize)]) -> Vec> { + let (stride, oy) = (self.stride.max(1), self.oy); + let mut out = Vec::with_capacity(ranges.len()); + let mut rest = self.data.as_mut_slice(); + // First row of `rest`. + let mut y = 0usize; + for &(start, end) in ranges { + let skip = start.saturating_sub(y).saturating_mul(stride).min(rest.len()); + rest = std::mem::take(&mut rest).split_at_mut(skip).1; + y = y.saturating_add(skip / stride); + let len = end.saturating_sub(y).saturating_mul(stride).min(rest.len()); + let (band, tail) = std::mem::take(&mut rest).split_at_mut(len); + out.push(RowBand { data: band, stride, y0: oy.saturating_add(y) }); + y = y.saturating_add(len / stride); + rest = tail; + } + out + } +} + +/// Mutable whole rows [y0, y0 + data.len() / stride) of a plane, addressed with the plane's +/// coordinates (see [`Plane::row_bands`]). +pub struct RowBand<'a> { + pub data: &'a mut [u16], + pub stride: usize, + pub y0: usize, +} + +impl RowBand<'_> { + #[inline(always)] + pub fn set(&mut self, x: usize, y: usize, v: u16) { + self.data[(y - self.y0) * self.stride + x] = v; + } + #[inline(always)] + pub fn row_mut(&mut self, y: usize) -> &mut [u16] { + let o = (y - self.y0) * self.stride; + &mut self.data[o..o + self.stride] + } } /// A frame's three planes, sized to the macroblock-aligned coded area plus margins. @@ -158,6 +200,16 @@ impl FrameBuf { } } + /// [`Plane::row_bands`] of all three planes for ranges of luma rows (chroma rows scaled + /// down): element `i` holds the bands of range `i`. + pub fn row_bands(&mut self, luma: &[(usize, usize)]) -> Vec<[RowBand<'_>; 3]> { + let ssy = self.subsampling_y; + let chroma: Vec<(usize, usize)> = luma.iter().map(|&(s, e)| (s >> ssy, e >> ssy)).collect(); + let [p0, p1, p2] = &mut self.planes; + let (b1, b2) = (p1.row_bands(&chroma), p2.row_bands(&chroma)); + p0.row_bands(luma).into_iter().zip(b1).zip(b2).map(|((y, u), v)| [y, u, v]).collect() + } + pub fn plane_width(&self, plane: usize) -> usize { if plane == 0 { self.width } else { (self.width + self.subsampling_x) >> self.subsampling_x } } @@ -285,3 +337,40 @@ impl MiInfo { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn rows(b: &RowBand) -> (usize, usize) { + (b.y0, b.data.len() / b.stride) + } + + #[test] + fn row_bands_split_whole_rows() { + let mut p = Plane::new(8, 20); + for (i, v) in p.data.iter_mut().enumerate() { + *v = (i / 8) as u16; + } + let bands = p.row_bands(&[(0, 8), (8, 16), (16, usize::MAX)]); + assert_eq!(bands.iter().map(rows).collect::>(), [(0, 8), (8, 8), (16, 4)]); + // Each band's row y holds the samples of plane row y. + for mut b in bands { + let y = b.y0 + 1; + assert!(b.row_mut(y).iter().all(|&v| v as usize == y)); + } + // Gaps between bands (the loop filter's even / odd turns). + let bands = p.row_bands(&[(0, 4), (10, 14), (18, 30)]); + assert_eq!(bands.iter().map(rows).collect::>(), [(0, 4), (10, 4), (18, 2)]); + } + + #[test] + fn row_bands_never_overlap_or_panic() { + let mut p = Plane::new(4, 10); + // Overlapping, descending and out-of-range ranges are cut to what is left. + let bands = p.row_bands(&[(2, 6), (4, 8), (1, 3), (9, 2), (50, 60), (usize::MAX, usize::MAX)]); + assert_eq!(bands.iter().map(rows).collect::>(), [(2, 4), (6, 2), (8, 0), (9, 0), (10, 0), (10, 0)]); + let mut empty = Plane::default(); + assert!(empty.row_bands(&[(0, 64), (64, usize::MAX)]).iter().all(|b| b.data.is_empty())); + } +} diff --git a/crates/av1/src/postfilter.rs b/crates/av1/src/postfilter.rs index ea157de54..46bf83562 100644 --- a/crates/av1/src/postfilter.rs +++ b/crates/av1/src/postfilter.rs @@ -1,20 +1,33 @@ //! In-loop post filters run by decode_frame_wrapup: loop filter (7.14), CDEF (7.15), //! super-resolution upscaling (7.16) and loop restoration (7.17). +use crate::frame::RowBand; use crate::spec_tables::*; use crate::state::FrameState; use crate::stats::{DecodeStats, Stage, Timer}; -/// The post-filters run on the frame worker; `_pool` is for row-parallel filtering (not yet used). -/// `draft`: only super-resolution (which sets the output size) runs. -pub(crate) fn apply(fs: &mut FrameState, stats: &mut DecodeStats, _pool: &crate::par::Pool, draft: bool) { +/// Luma rows per band of the row-parallel filters: whole 64x64 CDEF filter blocks, and no +/// transform block crosses a band edge. +const BAND_ROWS: usize = 64; + +/// The [start, end) luma rows of the bands covering `rows`: [`BAND_ROWS`] each (the last one +/// runs to the end of the plane), or one band for everything when `pool` is sequential. +pub(crate) fn bands(pool: &crate::par::Pool, rows: usize) -> Vec<(usize, usize)> { + let n = if pool.is_parallel() { rows.div_ceil(BAND_ROWS).max(1) } else { 1 }; + (0..n).map(|b| (b * BAND_ROWS, if b + 1 == n { usize::MAX } else { (b + 1) * BAND_ROWS })).collect() +} + +/// The post-filters run on the frame worker, each in row bands on `pool` (the result does not +/// depend on how the bands are scheduled). `draft`: only super-resolution (which sets the +/// output size) runs. +pub(crate) fn apply(fs: &mut FrameState, stats: &mut DecodeStats, pool: &crate::par::Pool, draft: bool) { let mut t = Timer::start(); let lvl = fs.fh.lf.level; if (lvl[0] != 0 || lvl[1] != 0) && !draft { - loop_filter(fs); + loop_filter(fs, pool); } stats.add(Stage::LoopFilter, t.lap()); - let cdef = if draft { None } else { crate::cdef::apply(fs) }; + let cdef = if draft { None } else { crate::cdef::apply(fs, pool) }; stats.add(Stage::Cdef, t.lap()); let (up_cur, up_cdef) = if fs.fh.use_superres { let c = crate::restoration::upscale(fs, &fs.cur); @@ -25,7 +38,7 @@ pub(crate) fn apply(fs: &mut FrameState, stats: &mut DecodeStats, _pool: &crate: }; stats.add(Stage::Superres, t.lap()); fs.cur = if fs.fh.lr.uses_lr && !draft { - crate::restoration::loop_restoration(fs, &up_cur, up_cdef.as_ref().unwrap_or(&up_cur)) + crate::restoration::loop_restoration(fs, &up_cur, up_cdef.as_ref().unwrap_or(&up_cur), pool) } else { up_cdef.unwrap_or(up_cur) }; @@ -35,28 +48,102 @@ pub(crate) fn apply(fs: &mut FrameState, stats: &mut DecodeStats, _pool: &crate: // --------------------------------------------------------------------------------------------- // Loop filter (7.14) -fn loop_filter(fs: &mut FrameState) { - let (mi_rows, mi_cols) = (fs.fh.mi_rows as usize, fs.fh.mi_cols as usize); - for plane in 0..fs.num_planes { - if plane == 0 || fs.fh.lf.level[1 + plane] != 0 { - for pass in 0..2 { - let row_step = if plane == 0 { 1 } else { 1 << fs.ssy }; - let col_step = if plane == 0 { 1 } else { 1 << fs.ssx }; - let mut row = 0; - while row < mi_rows { - let mut col = 0; - while col < mi_cols { - edge(fs, plane, pass, row, col); - col += col_step; - } - row += row_step; +/// Luma rows above a band that its horizontal edges read or write (p6 .. p0 of the widest +/// filter, rounded up to whole 4x4 rows); chroma: `LF_MARGIN >> subsampling_y`. +const LF_MARGIN: usize = 8; + +/// The loop filter in row bands, all of a frame's in one parallel pass (each join costs a +/// worker wake-up, which is slow on a busy machine). +/// +/// Vertical edges (pass 0) only touch the 4 rows of their own edge. A horizontal edge (pass 1) +/// reads and writes at most `filter_size / 2` samples on each side, never more than the +/// transform blocks on either side are high, so no two edges touch the same sample and +/// any order gives the spec's raster-order result. Transform blocks never cross a band edge, so +/// the only edges reaching outside a band are those on its top row, whose p side lies in the +/// band above. Each band therefore also filters the vertical edges of a copy of the +/// `LF_MARGIN` rows above it (the same work as the band above does on them), runs its top +/// horizontal edges against that copy, and afterwards the samples those edges changed are +/// written back; the band above never touches them. +fn loop_filter(fs: &mut FrameState, pool: &crate::par::Pool) { + let luma = bands(pool, fs.fh.mi_rows as usize * 4); + let planes: Vec = (0..fs.num_planes).filter(|&p| p == 0 || fs.fh.lf.level[1 + p] != 0).collect(); + let mut cur = std::mem::take(&mut fs.cur); + let f: &FrameState = fs; + // Unfiltered copies of the rows above each band but the first. + let margins: Vec<[Vec; 3]> = luma + .iter() + .map(|&(start, _)| { + std::array::from_fn(|p| { + let sub_y = if p == 0 { 0 } else { f.ssy }; + let pl = &cur.planes[p]; + let (y0, y1) = ((start.saturating_sub(LF_MARGIN)) >> sub_y, start >> sub_y); + let rows = pl.data.get(y0 * pl.stride..y1 * pl.stride); + if start == 0 || !planes.contains(&p) { Vec::new() } else { rows.map(<[u16]>::to_vec).unwrap_or_default() } + }) + }) + .collect(); + let mut bands: Vec<([RowBand; 3], [Vec; 3])> = cur.row_bands(&luma).into_iter().zip(margins).collect(); + let changed = pool.map_mut(&mut bands, |b, (band, margin)| { + let mut changed: [Vec<(usize, u16)>; 3] = Default::default(); + for &p in &planes { + changed[p] = filter_band(f, p, luma[b], &mut band[p], &mut margin[p]); + } + changed + }); + drop(bands); + for (&(start, _), ch) in luma.iter().zip(&changed) { + for (p, list) in ch.iter().enumerate() { + let sub_y = if p == 0 { 0 } else { f.ssy }; + let pl = &mut cur.planes[p]; + let first = (start.saturating_sub(LF_MARGIN) >> sub_y) * pl.stride; + for &(i, v) in list { + if let Some(d) = pl.data.get_mut(first + i) { + *d = v; } } } } + fs.cur = cur; } -fn edge(fs: &mut FrameState, plane: usize, pass: usize, row: usize, col: usize) { +/// Both loop filter passes over the band of `plane` holding luma rows [start, end); `margin` is +/// an unfiltered copy of the rows above the band (empty for the first band). Returns the samples +/// of `margin` that the band's top horizontal edges changed, as (index, value). +fn filter_band(fs: &FrameState, plane: usize, (start, end): (usize, usize), band: &mut RowBand, margin: &mut [u16]) -> Vec<(usize, u16)> { + let mi_rows = fs.fh.mi_rows as usize; + let (r0, r1) = (start / 4, mi_rows.min(end / 4)); + let sub_y = if plane == 0 { 0 } else { fs.ssy }; + let edges = |band: &mut RowBand, mut above: Option<&mut RowBand>, pass: usize, rows: std::ops::Range| { + let row_step = if plane == 0 { 1 } else { 1 << fs.ssy }; + let col_step = if plane == 0 { 1 } else { 1 << fs.ssx }; + let mi_cols = fs.fh.mi_cols as usize; + let mut row = rows.start; + while row < rows.end { + let mut col = 0; + while col < mi_cols { + edge(fs, band, above.as_deref_mut(), plane, pass, row, col); + col += col_step; + } + row += row_step; + } + }; + edges(band, None, 0, r0..r1); + if margin.is_empty() { + edges(band, None, 1, r0..r1); + return Vec::new(); + } + let stride = band.stride; + let y0 = start.saturating_sub(LF_MARGIN) >> sub_y; + let mut above = RowBand { data: margin, stride, y0 }; + edges(&mut above, None, 0, r0.saturating_sub(LF_MARGIN / 4)..r0); + let before = above.data.to_vec(); + edges(band, Some(&mut above), 1, r0..r1); + above.data.iter().zip(&before).enumerate().filter(|(_, (a, b))| a != b).map(|(i, (&a, _))| (i, a)).collect() +} + +/// One loop filter edge (7.14.2) at mode info position (row, col): 4 lines of samples across +/// it. `above` holds the rows above `band` for horizontal edges on the band's top row. +fn edge(fs: &FrameState, band: &mut RowBand, above: Option<&mut RowBand>, plane: usize, pass: usize, row: usize, col: usize) { let (sub_x, sub_y) = if plane == 0 { (0, 0) } else { (fs.ssx, fs.ssy) }; let (dx, dy) = if pass == 0 { (1i32, 0i32) } else { (0, 1) }; let x = col * 4; @@ -102,16 +189,40 @@ fn edge(fs: &mut FrameState, plane: usize, pass: usize, row: usize, col: usize) return; } let t = LfThresh::new(limit, blimit, thresh, fs.bit_depth); - let pl = &mut fs.cur.planes[plane]; - let stride = pl.stride; - let (step, along) = if pass == 0 { (1, stride) } else { (stride, 1) }; - let base = yp * stride + xp; let chroma = plane > 0; + let stride = band.stride; + if let Some(above) = above.filter(|_| pass == 1 && yp == band.y0) { + // The p side is in the rows above the band: filter each line in a copy that spans both. + let (n_above, n_band) = (above.data.len() / stride.max(1), band.data.len() / stride.max(1)); + for k in 0..4 { + let x = xp + k; + let mut line = [0u16; 2 * LF_LINE]; + for j in 1..=n_above.min(LF_LINE) { + line[LF_LINE - j] = above.data[(n_above - j) * stride + x]; + } + for j in 0..n_band.min(LF_LINE) { + line[LF_LINE + j] = band.data[j * stride + x]; + } + sample_filter(&mut line, LF_LINE, 1, chroma, filter_size, t); + for j in 1..=n_above.min(LF_LINE) { + above.data[(n_above - j) * stride + x] = line[LF_LINE - j]; + } + for j in 0..n_band.min(LF_LINE) { + band.data[j * stride + x] = line[LF_LINE + j]; + } + } + return; + } + let (step, along) = if pass == 0 { (1, stride) } else { (stride, 1) }; + let base = (yp - band.y0) * stride + xp; for k in 0..4 { - sample_filter(&mut pl.data, base + k * along, step, chroma, filter_size, t); + sample_filter(band.data, base + k * along, step, chroma, filter_size, t); } } +/// Samples on each side of an edge that the sample filter can read (at most p6 .. q6). +const LF_LINE: usize = 8; + /// Adaptive filter strength (7.14.4): (lvl, limit, blimit, thresh). fn strength(fs: &FrameState, row: usize, col: usize, plane: usize, pass: usize) -> (i32, i32, i32, i32) { let mi = &fs.mi; diff --git a/crates/av1/src/restoration.rs b/crates/av1/src/restoration.rs index 693e27284..923d25957 100644 --- a/crates/av1/src/restoration.rs +++ b/crates/av1/src/restoration.rs @@ -1,6 +1,8 @@ //! Super-resolution upscaling (7.16) and loop restoration (7.17). -use crate::frame::{FrameBuf, Plane}; +use crate::frame::{FrameBuf, Plane, RowBand}; +use crate::par::Pool; +use crate::postfilter::bands; use crate::spec_tables::*; use crate::state::FrameState; @@ -70,56 +72,66 @@ impl LrCtx<'_> { } /// Loop restoration (7.17): returns LrFrame given UpscaledCurrFrame and UpscaledCdefFrame. -pub(crate) fn loop_restoration(fs: &FrameState, cur: &FrameBuf, cdef: &FrameBuf) -> FrameBuf { +/// Bands of 64 luma rows are restored in parallel: every unit reads only the two input frames +/// and writes only its own samples. +pub(crate) fn loop_restoration(fs: &FrameState, cur: &FrameBuf, cdef: &FrameBuf, pool: &Pool) -> FrameBuf { let fh = &fs.fh; let mut lr = cdef.clone(); let up_w = fh.upscaled_width as i32; let fr_h = fh.frame_height as i32; let bd = fs.bit_depth; - for plane in 0..fs.num_planes { - let rtype_frame = fh.lr.frame_restoration_type[plane]; - if rtype_frame == RESTORE_NONE as u8 { - continue; - } - let (sub_x, sub_y) = if plane > 0 { (fs.ssx as i32, fs.ssy as i32) } else { (0, 0) }; - let unit_size = fh.lr.loop_restoration_size[plane] as i32; - let unit_rows = fs.lr_unit_rows[plane] as i32; - let unit_cols = fs.lr_unit_cols[plane] as i32; - let plane_end_x = ((up_w + sub_x) >> sub_x) - 1; - let plane_end_y = ((fr_h + sub_y) >> sub_y) - 1; - let mut row = 0; - while row * 4 < fr_h { - let luma_y = row * 4; - let stripe_num = (luma_y + 8) / 64; - let stripe_start_y = (-8 + stripe_num * 64) >> sub_y; - let stripe_end_y = stripe_start_y + (64 >> sub_y) - 1; - let ctx = LrCtx { cur: &cur.planes[plane], cdef: &cdef.planes[plane], plane_end_x, plane_end_y, stripe_start_y, stripe_end_y }; - let unit_row = (unit_rows - 1).min(((row * 4 + 8) >> sub_y) / unit_size); - let mut col = 0; - while col * 4 < up_w { - let unit_col = (unit_cols - 1).min(((col * 4) >> sub_x) / unit_size); - let x = (col * 4) >> sub_x; - let y = (row * 4) >> sub_y; - let w = (4 >> sub_x).min(plane_end_x - x + 1); - let h = (4 >> sub_y).min(plane_end_y - y + 1); - if w > 0 && h > 0 { - let ui = (unit_row * unit_cols + unit_col) as usize; - match fs.lr.lr_type[plane][ui] { - t if t == RESTORE_WIENER as u8 => wiener(fs, &ctx, &mut lr.planes[plane], plane, ui, x, y, w, h, bd), - t if t == RESTORE_SGRPROJ as u8 => self_guided(fs, &ctx, &mut lr.planes[plane], plane, ui, x, y, w, h, bd), - _ => {} + let ranges = bands(pool, fh.frame_height as usize); + let mut planes = lr.row_bands(&ranges); + pool.map_mut(&mut planes, |b, band| { + // Rows of 4 luma samples in this band. + let (start, end) = ranges[b]; + let (row0, row1) = ((start / 4) as i32, (end / 4).min(fh.frame_height as usize) as i32); + for (plane, out) in band.iter_mut().enumerate().take(fs.num_planes) { + let rtype_frame = fh.lr.frame_restoration_type[plane]; + if rtype_frame == RESTORE_NONE as u8 { + continue; + } + let (sub_x, sub_y) = if plane > 0 { (fs.ssx as i32, fs.ssy as i32) } else { (0, 0) }; + let unit_size = fh.lr.loop_restoration_size[plane] as i32; + let unit_rows = fs.lr_unit_rows[plane] as i32; + let unit_cols = fs.lr_unit_cols[plane] as i32; + let plane_end_x = ((up_w + sub_x) >> sub_x) - 1; + let plane_end_y = ((fr_h + sub_y) >> sub_y) - 1; + let mut row = row0; + while row < row1 && row * 4 < fr_h { + let luma_y = row * 4; + let stripe_num = (luma_y + 8) / 64; + let stripe_start_y = (-8 + stripe_num * 64) >> sub_y; + let stripe_end_y = stripe_start_y + (64 >> sub_y) - 1; + let ctx = LrCtx { cur: &cur.planes[plane], cdef: &cdef.planes[plane], plane_end_x, plane_end_y, stripe_start_y, stripe_end_y }; + let unit_row = (unit_rows - 1).min(((row * 4 + 8) >> sub_y) / unit_size); + let mut col = 0; + while col * 4 < up_w { + let unit_col = (unit_cols - 1).min(((col * 4) >> sub_x) / unit_size); + let x = (col * 4) >> sub_x; + let y = (row * 4) >> sub_y; + let w = (4 >> sub_x).min(plane_end_x - x + 1); + let h = (4 >> sub_y).min(plane_end_y - y + 1); + if w > 0 && h > 0 { + let ui = (unit_row * unit_cols + unit_col) as usize; + match fs.lr.lr_type[plane][ui] { + t if t == RESTORE_WIENER as u8 => wiener(fs, &ctx, out, plane, ui, x, y, w, h, bd), + t if t == RESTORE_SGRPROJ as u8 => self_guided(fs, &ctx, out, plane, ui, x, y, w, h, bd), + _ => {} + } } + col += 1; } - col += 1; + row += 1; } - row += 1; } - } + }); + drop(planes); lr } #[allow(clippy::too_many_arguments)] -fn wiener(fs: &FrameState, ctx: &LrCtx, out: &mut Plane, plane: usize, ui: usize, x: i32, y: i32, w: i32, h: i32, bd: u32) { +fn wiener(fs: &FrameState, ctx: &LrCtx, out: &mut RowBand, plane: usize, ui: usize, x: i32, y: i32, w: i32, h: i32, bd: u32) { let round0: u32 = if bd == 12 { 5 } else { 3 }; let round1: u32 = if bd == 12 { 9 } else { 11 }; let coef = &fs.lr.lr_wiener[plane][ui]; @@ -163,7 +175,7 @@ fn wiener(fs: &FrameState, ctx: &LrCtx, out: &mut Plane, plane: usize, ui: usize } #[allow(clippy::too_many_arguments)] -fn self_guided(fs: &FrameState, ctx: &LrCtx, out: &mut Plane, plane: usize, ui: usize, x: i32, y: i32, w: i32, h: i32, bd: u32) { +fn self_guided(fs: &FrameState, ctx: &LrCtx, out: &mut RowBand, plane: usize, ui: usize, x: i32, y: i32, w: i32, h: i32, bd: u32) { let set = fs.lr.lr_sgr_set[plane][ui] as usize; let mut flt0 = [[0i32; 4]; 4]; let mut flt1 = [[0i32; 4]; 4]; diff --git a/crates/av1/tests/common/mod.rs b/crates/av1/tests/common/mod.rs index 0892fd697..13b4567d6 100644 --- a/crates/av1/tests/common/mod.rs +++ b/crates/av1/tests/common/mod.rs @@ -16,6 +16,11 @@ pub fn ffmpeg_dav1d() -> Option { ffmpeg_with(&[("-decoders", "libdav1d")]) } +/// ffmpeg with libaom (4:2:2 and 4:4:4 fixtures, which SVT-AV1 cannot make) and libdav1d. +pub fn ffmpeg_libaom() -> Option { + ffmpeg_with(&[("-encoders", "libaom-av1"), ("-decoders", "libdav1d")]) +} + /// Builds such as the Windows "essentials" ones lack these libraries; skip rather than fail. fn ffmpeg_with(needs: &[(&str, &str)]) -> Option { let ff = filmcraft_testkit::ffmpeg_or_skip("av1 oracle")?; @@ -38,9 +43,11 @@ pub fn run(ff: &Path, args: &[&str]) { assert!(out.status.success(), "ffmpeg {:?} failed: {}", args, String::from_utf8_lossy(&out.stderr)); } -/// An AV1 fixture: a lavfi source encoded by ffmpeg's libsvtav1 into IVF. +/// An AV1 fixture: a lavfi source encoded by ffmpeg (libsvtav1 unless [`Spec::encoder`] says +/// otherwise) into IVF. pub struct Spec { pub name: &'static str, + pub encoder: &'static str, pub lavfi: String, pub frames: u32, pub pix_fmt: &'static str, @@ -50,7 +57,12 @@ pub struct Spec { impl Spec { pub fn new(name: &'static str, lavfi: impl Into, frames: u32, pix_fmt: &'static str, enc: &[&str]) -> Spec { - Spec { name, lavfi: lavfi.into(), frames, pix_fmt, enc: enc.iter().map(|s| s.to_string()).collect() } + Spec { name, encoder: "libsvtav1", lavfi: lavfi.into(), frames, pix_fmt, enc: enc.iter().map(|s| s.to_string()).collect() } + } + + pub fn encoder(mut self, encoder: &'static str) -> Spec { + self.encoder = encoder; + self } } @@ -60,7 +72,7 @@ pub fn make(ff: &Path, spec: &Spec) -> PathBuf { let tmp = filmcraft_testkit::temp_path(&path); let frames = spec.frames.to_string(); let t = tmp.to_str().unwrap().to_string(); - let mut args: Vec<&str> = vec!["-f", "lavfi", "-i", spec.lavfi.as_str(), "-frames:v", frames.as_str(), "-pix_fmt", spec.pix_fmt, "-c:v", "libsvtav1"]; + let mut args: Vec<&str> = vec!["-f", "lavfi", "-i", spec.lavfi.as_str(), "-frames:v", frames.as_str(), "-pix_fmt", spec.pix_fmt, "-c:v", spec.encoder]; args.extend(spec.enc.iter().map(String::as_str)); args.extend(["-f", "ivf", t.as_str()]); run(ff, &args); diff --git a/crates/av1/tests/oracle_inter.rs b/crates/av1/tests/oracle_inter.rs index fcd43b087..06a4d88fe 100644 --- a/crates/av1/tests/oracle_inter.rs +++ b/crates/av1/tests/oracle_inter.rs @@ -101,3 +101,19 @@ fn inter_1080p() { check_bit_exact(&ff, &s); } } + +/// 4:2:2 and 4:4:4 (libaom; SVT-AV1 only makes 4:2:0): the post-filters run in row bands whose +/// chroma rows depend on the vertical subsampling, so these check the band edges there too. +#[test] +fn inter_chroma_444_422() { + let Some(ff) = ffmpeg_libaom() else { return }; + let p = ["-cpu-used", "8", "-crf", "40", "-enable-restoration", "1"]; + let src = "testsrc2=s=416x400:r=25,noise=alls=8:allf=t"; + for s in [ + Spec::new("inter_aom_444", src, 6, "yuv444p", &p).encoder("libaom-av1"), + Spec::new("inter_aom_422", src, 6, "yuv422p", &p).encoder("libaom-av1"), + Spec::new("inter_aom_444_10bit", src, 6, "yuv444p10le", &p).encoder("libaom-av1"), + ] { + check_bit_exact(&ff, &s); + } +} diff --git a/crates/av1/tests/perf.rs b/crates/av1/tests/perf.rs index dd36a10e3..9047605aa 100644 --- a/crates/av1/tests/perf.rs +++ b/crates/av1/tests/perf.rs @@ -51,8 +51,8 @@ fn run(frames: &[Vec], threads: usize) -> Run { } fn bench(spec: &Spec) { - // AV1_PERF_ONLY= limits the run to matching fixtures. - if std::env::var("AV1_PERF_ONLY").is_ok_and(|f| !spec.name.contains(f.as_str())) { + // AV1_PERF_ONLY=[,...] limits the run to matching fixtures. + if std::env::var("AV1_PERF_ONLY").is_ok_and(|f| !f.split(',').any(|f| spec.name.contains(f))) { return; } let Some(ff) = ffmpeg() else { return }; @@ -68,7 +68,6 @@ fn bench(spec: &Spec) { } } let b = best.unwrap(); - let n = b.stats.frames.max(1) as f64; let cpu_fps = b.cpu.map(|c| format!("{:6.1}", b.shown as f64 / c)).unwrap_or_else(|| " ?".into()); println!( "{:<26} {:>3} shown {:>3} decoded {:>3} tiles/frame | 1 thread: {} fps (thread CPU) {:6.1} fps (wall)", @@ -79,24 +78,35 @@ fn bench(spec: &Spec) { cpu_fps, b.shown as f64 / b.wall ); - let total = b.stats.total_secs(); - let mut line = String::from(" ms/frame:"); + println!("{}", stage_line(" ms/frame:", &b.stats)); + // All cores: best of 3 by wall time. + let threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1); + let mut best: Option = None; + for _ in 0..3 { + let r = run(&frames, threads); + assert_eq!(r.shown, b.shown); + if best.as_ref().is_none_or(|b| r.wall < b.wall) { + best = Some(r); + } + } + let m = best.unwrap(); + println!(" {threads} threads: {:6.1} fps (wall, load average {})", b.shown as f64 / m.wall, load_average()); + // Per-stage wall time on the frame workers (stages of different frames overlap). + println!("{}", stage_line(&format!(" {threads} threads, ms/frame:"), &m.stats)); +} + +/// Per-stage milliseconds per frame and share of the total. +fn stage_line(label: &str, stats: &DecodeStats) -> String { + let n = stats.frames.max(1) as f64; + let total = stats.total_secs(); + let mut line = String::from(label); for s in Stage::ALL { - let v = b.stats.secs(s); + let v = stats.secs(s); if v > 0.0005 { line += &format!(" {} {:.2} ({:.0}%)", s.name(), 1e3 * v / n, 100.0 * v / total); } } - println!("{line}"); - // All cores: best of 3 by wall time. - let threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1); - let mut best_wall = f64::INFINITY; - for _ in 0..3 { - let r = run(&frames, threads); - assert_eq!(r.shown, b.shown); - best_wall = best_wall.min(r.wall); - } - println!(" {threads} threads: {:6.1} fps (wall, load average {})", b.shown as f64 / best_wall, load_average()); + line } #[test] @@ -110,4 +120,9 @@ fn decode_throughput() { // Higher quality (more coefficients, finer partitions) and a multi-tile stream (4x2 tiles). bench(&Spec::new("perf_1080p_gop_hq", src, 60, "yuv420p", &p("18", "keyint=60"))); bench(&Spec::new("perf_1080p_gop_tiles", src, 60, "yuv420p", &p("30", "keyint=60:tile-columns=2:tile-rows=1"))); + // Low delay (each frame references the previous one, as screen and camera recorders encode): + // frames cannot overlap, so a frame's own parallelism decides the speed. + bench(&Spec::new("perf_1080p_lowdelay", src, 60, "yuv420p", &p("30", "keyint=60:pred-struct=1"))); + // One tile at 2160p: the post-filters' row bands are most of the parallelism there. + bench(&Spec::new("perf_2160p_gop", "testsrc2=s=3840x2160:r=25,noise=alls=6:allf=t", 30, "yuv420p", &p("30", "keyint=30"))); } diff --git a/docs/gaps.md b/docs/gaps.md index 564541067..956d03a2b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -219,10 +219,14 @@ 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). AV1 - software decode is ~4 fps at 1080p single-threaded; 8K is not real time. + software decode of a single-tile stream is bound by its serial tile decode (65–80 % of a + frame; no row-level frame threading, references are waited for as whole frames); 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. + (#424); Render Audio still renders whole audio segments. AV1 software decode runs deblocking, + CDEF and loop restoration in parallel 64-row bands, bit-exact (#847; 1080p GOP 1.16–1.30×, + low delay 1.28×, 2160p 1.14× at 16 threads). - **Estimate:** 30–60 h. Doc: [performance.md](performance.md). ## G17. Audio device and playback reliability diff --git a/docs/performance.md b/docs/performance.md index 96cb5f625..4b2c588b1 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -518,6 +518,49 @@ decode, and the UI-thread present (upload and draw, including the backdrop copie blended layers) stays at 9–10 ms p50, as for `stack3`. Before, every frame was composited on the CPU (≈ 2 cores more at 23.976 fps) and Full resolution could not keep up at this load. +## Results (#847: AV1 in-loop filters in parallel row bands, before → after) + +The AV1 decoder ran deblocking, CDEF and loop restoration on one thread per frame. A single-tile +stream (what SVT-AV1 and most encoders write by default) has no other parallelism inside a +frame, and a frame's references are waited for as whole frames, so this serial tail sat on the +critical path of every frame that references it. The three filters now run in bands of 64 luma +rows on the decoder's rayon pool, one join per filter per frame (the loop filter recomputes the +8 rows above each band rather than splitting into two passes, because every join costs a worker +wake-up, which is slow on a busy machine). Output is bit-exact with the single-threaded path and +with libdav1d (all oracle and conformance tests, plus new 4:2:2 / 4:4:4 libaom fixtures); with +one thread the code path is the old one. + +Linux, AMD Ryzen 7 7800X3D (8 cores / 16 threads), 2026-10-11, shared with other agents' builds; +base = `2ff3f5e`, after = this change, 16 decoder threads, SVT-AV1 preset 8 fixtures from +`crates/av1/tests/perf.rs` (`perf_1080p_lowdelay` and `perf_2160p_gop` are new there). + +`av1dec` example (`AV1_LOOPS=2 AV1_THREADS=16`), 8 rounds alternating base / after per fixture, +load average 11–27; speed-up = base wall time / after wall time of the adjacent pair: + +| fixture | fps base → after (median) | paired speed-up, median (range) | after faster | CPU ms / frame (all threads) | +|---|---|---|---|---| +| `perf_1080p_lowdelay` (each frame refs the last) | 31.0 → **39.9** | 1.28 (0.85–1.48) | 6 / 8 | 44.2 → 55.0 | +| `perf_1080p_gop` (crf 30) | 74.8 → **97.7** | 1.30 (1.06–1.41) | 8 / 8 | 30.2 → 35.9 | +| `perf_1080p_gop_hq` (crf 18) | 32.0 → **37.5** | 1.16 (1.04–1.29) | 8 / 8 | 66.9 → 77.1 | +| `perf_2160p_gop` (one tile) | 23.0 → **25.3** | 1.14 (1.08–1.41) | 8 / 8 | 103.1 → 115.5 | +| `perf_1080p_intra` (10 key frames) | 121.3 → 138.1 | 1.03 (0.92–1.21) | 4 / 8 | 40.8 → 45.5 | + +Per-stage wall time on the frame worker (`tests/perf.rs`, 16 threads, ms per frame, median of 2 +alternating rounds at load average 8–11): + +| fixture | 16 threads fps | deblock | CDEF | restoration | +|---|---|---|---|---| +| `perf_1080p_lowdelay` | 31.8 → **48.7** | 2.03 → 0.63 | 5.16 → 1.62 | 5.81 → 1.40 | +| `perf_2160p_gop` | 24.0 → **31.9** | 2.78 → 0.81 | 7.38 → 3.74 | 7.84 → 2.70 | +| `perf_1080p_gop_hq` | 34.8 → 33.6 | 1.10 → 0.55 | 6.53 → 3.08 | 4.94 → 1.65 | + +One thread is unchanged (`perf_1080p_lowdelay`, 8 pairs: 2.19–2.20 s both). The extra CPU time +is mostly rayon workers spinning briefly before they sleep after each join, plus the loop +filter's recomputed margin rows. Key-frame-only streams gain little: their frames are +independent, so frame threads already keep every core busy. What remains is the tile decode +itself (entropy decoding and reconstruction of a single tile, 65–80 % of a frame), which only +row-level frame threading (waiting for reference rows instead of whole frames) can overlap. + ## Results (M4.10: 4K VP9, AV1 and HEVC, before → after) Baseline = commit `a814ff6` (decoders unchanged since M4.8); after = M4.10. Interleaved runs on From f4bbbd2f64330a327d10a8a0e71bb9feee74241f Mon Sep 17 00:00:00 2001 From: KitsuneSemCalda Date: Sat, 10 Oct 2026 22:31:17 -0300 Subject: [PATCH 166/260] Fix OmaStore exec paths (no usr/ prefix in the release tarball) --- omastore.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/omastore.toml b/omastore.toml index 8d1ee53ae..ede9dbd8d 100644 --- a/omastore.toml +++ b/omastore.toml @@ -4,8 +4,8 @@ icon = "assets/app-icon/hicolor/256x256/apps/ai.storyteller.filmcraft.png" [linux.x86_64] asset = "filmcraft-{version}-linux-x86_64.tar.gz" -exec = "filmcraft-{version}-linux-x86_64/usr/bin/filmcraft" +exec = "filmcraft-{version}-linux-x86_64/bin/filmcraft" [linux.aarch64] asset = "filmcraft-{version}-linux-aarch64.tar.gz" -exec = "filmcraft-{version}-linux-aarch64/usr/bin/filmcraft" +exec = "filmcraft-{version}-linux-aarch64/bin/filmcraft" From 42dac5f699fc12c3cfa71d8d90e6a0ee1a691fbf Mon Sep 17 00:00:00 2001 From: opentechnepal <105809901+opentechnepal@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:44:50 +0200 Subject: [PATCH 167/260] #243: text fields keep unmodified keys so IMEs can compose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A focused text field now owns every unmodified key, so an input method can compose there. Vietnamese Telex builds its diacritics from plain letters (`a` + `s` → `á`, `a` + `r` → `ả`, `a` + `j` → `ạ`), and the editor bound `A` `B` `C` `H` `J` `K` `L` `N` `P` `R` `T` `U` `V` `Y` `Z` as single-key tool shortcuts, which consumed those presses before any field saw them. No diacritic could be typed (#243). `egui_wants_keyboard_input` was not enough to guard this: it is read before the pass lays the UI out, so it still reports the previous pass, and it is true for any focused widget, so using it would leave the editor without its single-key tool shortcuts while a slider has focus. `text_edit_focused` is the precise check. Chords with a modifier keep firing while a field is focused: an IME does not consume Ctrl+S and friends. Esc (trim cancel) is handled before the guard and is unaffected. Tests: the four cases in `text_focus_shortcut_tests` fail without the guard (`an_unmodified_key_reaches_a_focused_text_field`, `the_telex_trigger_keys_are_left_to_the_field`) and pass with it. --- crates/ui-egui/src/lib.rs | 67 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9c4c526f7..3bb22498b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1349,6 +1349,12 @@ impl FilmcraftApp { if ctx.egui_wants_keyboard_input() || self.dialog == Some(Dialog::Shortcuts) { return; } + // A focused text field takes every unmodified key, including the ones our shortest + // shortcuts use, so an input method can compose there (Vietnamese Telex, Korean, …; #243). + // `egui_wants_keyboard_input` above is not enough: it is read before this pass lays the UI + // out, so it still reports the previous pass, and it is true for any focused widget, which + // would leave the editor without its single-key tool shortcuts while a slider has focus. + let text_focused = ctx.text_edit_focused(); // Esc cancels a dynamic trim in progress 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!({})); @@ -1391,6 +1397,13 @@ impl FilmcraftApp { 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 { + // With a text field focused, an unmodified key belongs to the field (and to the input + // method composing in it), not to a tool shortcut: `J`, `Z`, `R`, `A`… are the keys + // Vietnamese Telex combines into diacritics (#243). Chords with a modifier stay: + // Ctrl+S and friends are not something a text field or an IME consumes. + if lets_text_field_keep(*m, text_focused) { + continue; + } if i.consume_key(*m, *k) { fire.push(id.clone()); } @@ -2265,6 +2278,60 @@ mod audio_recovery_tests { } } +/// Whether a shortcut chord must be left alone because a text field is focused. +/// +/// A focused text field owns every unmodified key: an input method composes there from plain +/// letters (Vietnamese Telex builds `á` from `a` + `s`, `ả` from `a` + `r`, `ạ` from `a` + `j`), +/// and our single-key tool shortcuts would swallow them before the field sees them (#243). Chords +/// with a modifier are not what a text field or an IME consumes, so they keep working. +fn lets_text_field_keep(modifiers: egui::Modifiers, text_focused: bool) -> bool { + text_focused && menus::specificity(modifiers) == 0 +} + +#[cfg(test)] +mod text_focus_shortcut_tests { + use super::lets_text_field_keep; + use egui::{Key, Modifiers}; + + /// Vietnamese Telex composes diacritics from unmodified letters (`a` + `s` → `á`, `a` + `r` → + /// `ả`, `a` + `j` → `ạ`), and the editor binds `J`, `Z`, `R`, `A`, `C`, `T`, `K`, `L`, `B`, `N`, + /// `P`, `V`, `Y`, `U`, `H` as single-key tool shortcuts. While a text field is focused those + /// keys must reach the field, or no diacritic can ever be typed (#243). + #[test] + fn an_unmodified_key_reaches_a_focused_text_field() { + assert!(lets_text_field_keep(Modifiers::NONE, true), "the IME needs the plain letter keys"); + } + + /// Without a text field the editor keeps its single-key tool shortcuts. + #[test] + fn an_unmodified_key_still_fires_without_a_text_field() { + assert!(!lets_text_field_keep(Modifiers::NONE, false)); + } + + /// A modifier chord is not what a text field or an IME consumes, so it keeps firing either way. + #[test] + fn a_modified_chord_still_fires_while_text_is_focused() { + for m in [Modifiers::COMMAND, Modifiers::CTRL, Modifiers::SHIFT, Modifiers::ALT, Modifiers::COMMAND | Modifiers::SHIFT] { + assert!(!lets_text_field_keep(m, true), "{m:?} is not an IME key"); + } + } + + /// The keys a Vietnamese Telex IME needs must be exactly the ones we now hand to the field. + #[test] + fn the_telex_trigger_keys_are_left_to_the_field() { + // s=acute, f=grave, r=hook above, x=tilde, j=dot below, w=horn, z=remove diacritic + for key in [Key::S, Key::F, Key::R, Key::X, Key::J, Key::W, Key::Z] { + assert!(lets_text_field_keep(Modifiers::NONE, true), "{key:?} must reach the IME"); + } + // The letters the editor binds as tools are the ones Telex composes with, so they must be + // left to the field too: `A` (Track Select Forward), `R` (Rate Stretch), `J` (Shuttle Left), + // `Z` (Zoom), `C` (Razor), `T` (Type). + for key in [Key::A, Key::R, Key::J, Key::Z, Key::C, Key::T] { + assert!(lets_text_field_keep(Modifiers::NONE, true), "{key:?} is a Telex key the tools stole"); + } + } +} + /// A physical-pixel screen position as a point in the window's UI space, given the window's content origin in points. /// /// Assumes the whole virtual screen uses the window's `pixels_per_point`: with monitors of mixed From 3f9c1421d91adb144cf49f20478b9bb406f18a40 Mon Sep 17 00:00:00 2001 From: opentechnepal <105809901+opentechnepal@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:46:18 +0200 Subject: [PATCH 168/260] contributors: credit opentechnepal Takes the display name from the GitHub profile (sync_github_name), so it tracks the profile rather than a copy here. --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 5f5be7dda..04ed72431 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -70,6 +70,9 @@ sync_github_name = false [people.ossini] sync_github_name = true +[people.opentechnepal] +sync_github_name = true + [people.vangel76] real_name = "Valentin Klein" display_name = "Valentin Klein" From f733230b9f92ac33e67cee678f7b6150ba36533c Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 13:01:20 +1100 Subject: [PATCH 169/260] #823: import every MPEG audio stream, not only the first PID Transport and program streams listed a single audio stream, so file.import and New Sequence From Clip kept one clip. Probe every audio PID (capped), decode each independently, and place linked clips per stream. MP4/MKV already did this; MXF same-rate PCM is still interleaved as one stream. --- Cargo.lock | 1 + crates/codecs/src/mpeg.rs | 185 ++++++++++++++++++--- crates/engine/Cargo.toml | 1 + crates/engine/src/audio_placement_tests.rs | 26 +++ 4 files changed, 190 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9d1aa3311..edb01151b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1890,6 +1890,7 @@ dependencies = [ "filmcraft-frame", "filmcraft-geom", "filmcraft-interchange", + "filmcraft-isobmff", "filmcraft-media", "filmcraft-mxf", "filmcraft-project", diff --git a/crates/codecs/src/mpeg.rs b/crates/codecs/src/mpeg.rs index ca72c88b6..e2cd09ba1 100644 --- a/crates/codecs/src/mpeg.rs +++ b/crates/codecs/src/mpeg.rs @@ -7,8 +7,9 @@ //! first random-access picture; display order comes from the PTS (or, without timestamps, the //! GOP and temporal reference). Leading pictures of an open GOP decode from the previous GOP. //! - Audio: MPEG audio layers I-III (symphonia), AAC in ADTS or LATM (our decoder), AC-3 (our -//! decoder), Blu-ray / AVCHD and DVD LPCM. Audio frames are placed on the timeline from their -//! PTS relative to the first video frame, sample-contiguously (re-anchored only at gaps). +//! decoder), Blu-ray / AVCHD and DVD LPCM. Every audio PID is a stream (capped). Audio frames +//! are placed on the timeline from their PTS relative to the first video frame, +//! sample-contiguously (re-anchored only at gaps). use std::collections::{HashMap, VecDeque}; use std::sync::{Arc, Mutex}; @@ -104,7 +105,8 @@ pub struct MpegSource { es: Option<(Arc<[u8]>, Vec>)>, video: Option, gop: GopCache, - audio: Option, + /// Playable audio tracks in file order: `info.audio_streams[k]` describes `audios[k]`. + audios: Vec, /// Why the video or audio stream cannot be decoded. unsupported_video: Option, unsupported_audio: Option, @@ -201,8 +203,8 @@ impl MpegSource { log::warn!("{name}: {w}"); } let vstream = file.find(Kind::Video, |c| vkind(c).is_some()).or_else(|| file.find(Kind::Video, |_| true)); - let astream = file.find(Kind::Audio, audio_supported).or_else(|| file.find(Kind::Audio, |_| true)); - if vstream.is_none() && astream.is_none() { + let has_audio = file.streams.iter().any(|s| s.kind() == Kind::Audio && !s.units.is_empty()); + if vstream.is_none() && !has_audio { return Err(CodecError::Unsupported(format!("{}: no video or audio streams", file.format.name()))); } let mut src = MpegSource { @@ -221,7 +223,7 @@ impl MpegSource { es: None, video: None, gop: GopCache::new(None), - audio: None, + audios: Vec::new(), unsupported_video: None, unsupported_audio: None, }; @@ -256,7 +258,7 @@ impl MpegSource { src.video = Some(track); src.describe_video(rate_from_pts)?; if let Some(file) = src.file.take() { - src.setup_audio(file, astream, origin); + src.setup_audio(file, origin); } } None => { @@ -273,12 +275,12 @@ impl MpegSource { bitrate: None, hdr: None, }); - src.setup_audio(file, astream, None); + src.setup_audio(file, None); } } } else { src.info.kind = MediaKind::AudioOnly; - src.setup_audio(file, astream, None); + src.setup_audio(file, None); } Ok(src) } @@ -326,7 +328,7 @@ impl MpegSource { es: Some((all, aus)), video: Some(track), gop: GopCache::new(None), - audio: None, + audios: Vec::new(), unsupported_video: None, unsupported_audio: None, }; @@ -347,15 +349,15 @@ impl MpegSource { /// Timeline position (samples at the audio stream's rate) of the first audio frame, and the /// number of samples in the audio stream. pub fn audio_extent(&self) -> Option<(i64, i64)> { - let a = self.audio.as_ref()?; + let a = self.audios.first()?; let first = *a.starts.first()?; let end = a.starts.last()? + *a.lens.last()? as i64; Some((first, end - first)) } - /// The demuxed stream indices of the presented video and audio. + /// The demuxed stream indices of the presented video and first audio. pub fn stream_indices(&self) -> (Option, Option) { - (self.video.as_ref().filter(|_| self.es.is_none()).map(|v| v.stream), self.audio.as_ref().map(|a| a.stream)) + (self.video.as_ref().filter(|_| self.es.is_none()).map(|v| v.stream), self.audios.first().map(|a| a.stream)) } /// Presentation timestamps (90 kHz) of the presented video frames in display order. Frames @@ -475,15 +477,31 @@ impl MpegSource { Ok(()) } - /// Choose and index the audio stream; `origin`: PTS of the first video frame. - fn setup_audio(&mut self, file: File, astream: Option, origin: Option) { - let track = astream.and_then(|ai| self.audio_track(&file, ai, origin)); + /// Index every audio stream the container carries (capped); `origin`: PTS of the first video frame. + fn setup_audio(&mut self, file: File, origin: Option) { + let indices: Vec = file + .streams + .iter() + .enumerate() + .filter(|(_, s)| s.kind() == Kind::Audio && !s.units.is_empty()) + .map(|(i, _)| i) + .take(filmcraft_media::MAX_AUDIO_STREAMS) + .collect(); + let mut tracks = Vec::new(); + for ai in indices { + if let Some(t) = self.audio_track(&file, ai, origin) { + tracks.push(t); + } + } self.file = Some(file); - self.audio = track; + self.audios = tracks; } - /// Mark the audio stream as not decodable (it still shows in the media info). + /// Mark the audio stream as not decodable (it still shows in the media info when nothing plays). fn audio_unsupported(&mut self, codec_name: &str, why: String) -> Option { + if !self.info.audio_streams.is_empty() { + return None; + } self.unsupported_audio = Some(why); self.info.audio_streams = vec![AudioStreamInfo { sample_rate: 48_000, channels: 2, codec: format!("{codec_name} (unsupported)"), bits_per_sample: None }]; @@ -594,14 +612,16 @@ impl MpegSource { next = Some(s + n as i64); } let total = next.unwrap_or(0).max(0); - if self.info.video.is_none() || self.unsupported_video.is_some() { + if self.unsupported_audio.take().is_some() { + self.info.audio_streams.clear(); + } + if self.info.audio_streams.is_empty() && (self.info.video.is_none() || self.unsupported_video.is_some()) { self.info.duration = Tick::from_units(total, rate.max(1) as i64); if self.info.video.is_none() { self.info.kind = MediaKind::AudioOnly; } } - self.info.audio_streams = - vec![AudioStreamInfo { sample_rate: rate.max(1), channels: channels.max(1) as u32, codec: codec_name, bits_per_sample: bits }]; + self.info.audio_streams.push(AudioStreamInfo { sample_rate: rate.max(1), channels: channels.max(1) as u32, codec: codec_name, bits_per_sample: bits }); Some(AudioTrack { stream: ai, codec, @@ -783,10 +803,16 @@ impl MediaSource for MpegSource { } fn audio(&self, start: i64, frames: usize, sample_rate: u32) -> Result { - if let Some(why) = &self.unsupported_audio { + self.audio_stream(0, start, frames, sample_rate) + } + + fn audio_stream(&self, stream: usize, start: i64, frames: usize, sample_rate: u32) -> Result { + if self.audios.is_empty() + && let Some(why) = &self.unsupported_audio + { return Err(MediaError::Unsupported(why.clone())); } - let a = self.audio.as_ref().ok_or(MediaError::NoStream("audio"))?; + let a = self.audios.get(stream).ok_or(MediaError::NoStream("audio"))?; let ch = a.channels; let ratio = a.rate as f64 / sample_rate as f64; let exact = a.rate == sample_rate; @@ -852,6 +878,7 @@ pub fn reader_opener(name: &str, head: &[u8], reader: &filmcraft_media::SharedRe #[cfg(test)] mod tests { use super::*; + use filmcraft_media::MediaSource; /// A mutated transport stream whose PTS span gave ~0 fps panicked on open with "attempt to /// divide by zero" (`FrameRate::tick_of`). @@ -868,6 +895,118 @@ mod tests { assert_eq!(stream_rate(None, Some(50.0)), FrameRate::from_f64(50.0)); } + /// MPEG-1 layer II frame, 48 kHz 192 kb/s (576 bytes). + fn mp2_frame(k: u8) -> Vec { + let mut v = vec![0xFF, 0xFD, 0xA4, 0x00]; + v.extend((0..572).map(|i| (i as u8) ^ k)); + v + } + + fn ts_bytes(prefix: u8, v: i64) -> [u8; 5] { + let v = v & ((1 << 33) - 1); + [(prefix << 4) | (((v >> 30) & 7) as u8) << 1 | 1, (v >> 22) as u8, (((v >> 15) & 0x7F) as u8) << 1 | 1, (v >> 7) as u8, ((v & 0x7F) as u8) << 1 | 1] + } + + fn pes(stream_id: u8, pts: Option, payload: &[u8]) -> Vec { + let mut h = Vec::new(); + if let Some(p) = pts { + h.extend(ts_bytes(2, p)); + } + let flags = if pts.is_some() { 0x80 } else { 0 }; + let len = 3 + h.len() + payload.len(); + let mut v = vec![0, 0, 1, stream_id, (len >> 8) as u8, len as u8, 0x80, flags, h.len() as u8]; + v.extend(h); + v.extend_from_slice(payload); + v + } + + /// Tiny TS muxer: PAT, PMT and PES packets in 188-byte packets. + struct TsMux { + out: Vec, + cc: std::collections::HashMap, + } + + impl TsMux { + fn packet(&mut self, pid: u16, pusi: bool, payload: &[u8]) -> usize { + let cc = self.cc.entry(pid).or_insert(0); + let mut p = vec![0x47, (pusi as u8) << 6 | (pid >> 8) as u8, pid as u8]; + let n = payload.len().min(184); + if n < 184 { + let al = 183 - n; + p.push(0x30 | *cc); + p.push(al as u8); + if al > 0 { + p.push(0); + p.extend(std::iter::repeat_n(0xFF, al - 1)); + } + } else { + p.push(0x10 | *cc); + } + *cc = (*cc + 1) & 15; + p.extend_from_slice(&payload[..n]); + self.out.extend(&p); + n + } + fn section(&mut self, pid: u16, body: &[u8]) { + let mut s = body.to_vec(); + s.extend(crc32_mpeg(&s).to_be_bytes()); + let mut payload = vec![0]; + payload.extend(s); + self.packet(pid, true, &payload); + } + fn psi(&mut self, streams: &[(u8, u16)]) { + let pat = [0x00, 0xB0, 13, 0, 1, 0xC1, 0, 0, 0, 1, 0xE1, 0x00]; + self.section(0, &pat); + let mut pmt = vec![0x02, 0xB0, 0, 0, 1, 0xC1, 0, 0, 0xE1, 0x00, 0xF0, 0]; + for &(st, pid) in streams { + pmt.extend([st, 0xE0 | (pid >> 8) as u8, pid as u8, 0xF0, 0]); + } + let len = pmt.len() - 3 + 4; + pmt[1] = 0xB0 | (len >> 8) as u8; + pmt[2] = len as u8; + self.section(0x100, &pmt); + } + fn pes(&mut self, pid: u16, data: &[u8]) { + let mut rest = data; + let mut first = true; + while !rest.is_empty() { + let n = self.packet(pid, first, rest); + rest = &rest[n..]; + first = false; + } + } + } + + fn crc32_mpeg(data: &[u8]) -> u32 { + let mut crc = 0xFFFF_FFFFu32; + for &b in data { + crc ^= (b as u32) << 24; + for _ in 0..8 { + crc = if crc & 0x8000_0000 != 0 { (crc << 1) ^ 0x04C1_1DB7 } else { crc << 1 }; + } + } + crc + } + + /// Dual MPEG audio in one transport stream: import used to keep only the first PID. + #[test] + fn every_audio_pid_is_a_stream() { + let mut mux = TsMux { out: Vec::new(), cc: Default::default() }; + mux.psi(&[(0x03, 0x1100), (0x03, 0x1101)]); + for k in 0..4u16 { + mux.pes(0x1100, &pes(0xC0, Some(90_000 + 2160 * k as i64), &mp2_frame(k as u8))); + mux.pes(0x1101, &pes(0xC1, Some(90_000 + 2160 * k as i64), &mp2_frame(k as u8 + 16))); + } + let source = MpegSource::open("dual.ts", mux.out.into()).unwrap(); + assert_eq!(source.info().audio_streams.len(), 2, "{:?}", source.info().audio_streams); + assert_eq!(source.info().audio_streams.iter().map(|a| a.sample_rate).collect::>(), vec![48_000, 48_000]); + let a0 = source.audio(0, 64, 48_000).unwrap(); + let a1 = source.audio_stream(1, 0, 64, 48_000).unwrap(); + assert_eq!(a0.channels.len(), 2); + assert_eq!(a1.channels.len(), 2); + assert!(source.audio_stream(2, 0, 64, 48_000).is_err()); + } + #[test] fn presentation_drops_leading_pictures_and_orders_by_key() { // decode order I(2) B(0) B(1) P(5) B(3) B(4): the leading B pictures are not presented diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index defe08a87..9ebd9f70e 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -46,4 +46,5 @@ web-time = "1" [dev-dependencies] filmcraft-testkit = { workspace = true } +filmcraft-isobmff = { workspace = true } image = { version = "0.25", default-features = false, features = ["png"] } diff --git a/crates/engine/src/audio_placement_tests.rs b/crates/engine/src/audio_placement_tests.rs index f765e055c..f23a932b1 100644 --- a/crates/engine/src/audio_placement_tests.rs +++ b/crates/engine/src/audio_placement_tests.rs @@ -168,6 +168,32 @@ fn missing_audio_tracks_are_cloned_from_the_destination() { assert_eq!(again, ids); } +/// #823: a container with two PCM tracks imports both streams, and overwrite places linked clips. +#[test] +fn import_keeps_every_audio_stream_and_places_linked_clips() { + use filmcraft_isobmff::{Brand, Mp4Writer, PcmConfig, SampleEntry, TrackConfig, WriteSample, WriterOptions}; + let mut mux = Mp4Writer::new(std::io::Cursor::new(Vec::new()), WriterOptions::new(Brand::Mov)).unwrap(); + for (rate, value) in [(48_000u32, 8192i16), (24_000, -16384)] { + let entry = SampleEntry::pcm(PcmConfig { bits: 16, float: false, big_endian: false, signed: true, channels: 1, sample_rate: rate as f64 }); + let track = mux.add_track(TrackConfig::new(entry, rate)).unwrap(); + let data: Vec = (0..rate / 10).flat_map(|_| value.to_le_bytes()).collect(); + mux.write_sample(track, WriteSample { data: &data, duration: rate / 10, composition_offset: 0, is_sync: true }).unwrap(); + } + let bytes = mux.finish().unwrap().into_inner(); + let mut s = Session::default(); + let item = crate::commands::import_bytes(&mut s, "/two-audio.mov", bytes.into(), None).unwrap(); + let streams = s.project.item(item).unwrap().as_media().unwrap().info.audio_streams.len(); + assert_eq!(streams, 2); + s.execute("file.newSequence", json!({"name": "S", "fromItem": item.0, "video": 1, "audio": 1})).unwrap(); + let seq = s.state.active_sequence.unwrap(); + let q = s.project.sequence(seq).unwrap(); + assert_eq!(q.audio_tracks.len(), 2); + let (a, b) = (&q.audio_tracks[0].items[0], &q.audio_tracks[1].items[0]); + assert_eq!((a.audio_stream, b.audio_stream), (0, 1)); + assert_eq!(a.link, b.link); + assert!(a.link.is_some()); +} + #[test] fn hostile_track_counts_are_an_error_not_a_crash() { let s = demo(); From 488259414bc8eded2e9f6ec105a45fbde7e03934 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 13:02:55 +1100 Subject: [PATCH 170/260] Enable Project panel Reveal in Finder for clips with a media file. file.reveal is enabled when a selected clip has a local path, greyed out with no selection, and the OS opener reveals the file (xdg-open of the parent folder on Linux). --- apps/filmcraft/src/main.rs | 1 + crates/engine/src/project_tools.rs | 3 +++ crates/engine/src/project_tools_tests.rs | 12 ++++++++++++ crates/ui-egui/src/panels/menu_dialogs.rs | 9 +++++---- crates/ui-egui/src/panels/project_views.rs | 7 +++++++ crates/ui-egui/tests/menus_ui.rs | 17 +++++++++++++++++ 6 files changed, 45 insertions(+), 4 deletions(-) diff --git a/apps/filmcraft/src/main.rs b/apps/filmcraft/src/main.rs index e24ed5da4..4b3634bf3 100644 --- a/apps/filmcraft/src/main.rs +++ b/apps/filmcraft/src/main.rs @@ -319,6 +319,7 @@ fn open_path(path: &str, reveal: bool) -> Result<(), String> { } c } else { + // Linux file managers have no select-file flag: open the parent folder instead. let mut c = std::process::Command::new("xdg-open"); let p = std::path::Path::new(path); c.arg(if reveal { p.parent().unwrap_or(p) } else { p }); diff --git a/crates/engine/src/project_tools.rs b/crates/engine/src/project_tools.rs index a0e2d181a..ac22abd49 100644 --- a/crates/engine/src/project_tools.rs +++ b/crates/engine/src/project_tools.rs @@ -14,6 +14,7 @@ //! | `file.projectSettings.general` / `.scratchDisks` | File ▸ Project Settings ▸ General… / Scratch Disks… | //! | `edit.find` / `edit.findNext` | Edit ▸ Find… (⌘F) / Find Next | //! | `edit.editOriginal` | Edit ▸ Edit Original (⌘E): the frontend opens the files | +//! | `file.reveal` | Project panel ▸ Reveal in Finder / Show in Explorer: the frontend reveals the media file | //! | `clip.editOffline` | Clip ▸ Edit Offline… | //! | `clip.sourceSettings` | Clip ▸ Source Settings… | //! | `clip.restoreCaptionsFromSource` | Clip ▸ Restore Captions from Source Clip (always disabled: no embedded caption reader) | @@ -163,6 +164,8 @@ pub(crate) fn commands() -> Vec { spec("edit.find", "Find…", &["Edit"], Some("Cmd+F"), FIND_PARAMS, always, find), spec("edit.findNext", "Find Next", &["Edit"], None, "{}", has_find, find_next), spec("edit.editOriginal", "Edit Original", &["Edit"], Some("Cmd+E"), r#"{"items":[id]?}"#, has_original, edit_original), + // Project panel context menu (not in the File menu): reveal the clip's media file. + query("file.reveal", "Reveal in Finder", &[], None, r#"{"items":[id]?}"#, has_file_selection, edit_original), // ---------------- Clip ---------------- spec( "clip.editOffline", diff --git a/crates/engine/src/project_tools_tests.rs b/crates/engine/src/project_tools_tests.rs index e00af7326..f9bac3808 100644 --- a/crates/engine/src/project_tools_tests.rs +++ b/crates/engine/src/project_tools_tests.rs @@ -361,6 +361,18 @@ fn edit_original_update_metadata_and_clip_items() { assert!(std::fs::read_to_string(&side).unwrap().contains("someone else")); } +#[test] +fn file_reveal_enabled_for_imported_clip() { + let dir = tmp_dir("reveal"); + let path = dir.join("take.mp4"); + make_movie(&path, DemoScene::Aurora, 64, 36, 8); + let (mut s, items, _) = session_with(&[&path]); + assert!(!s.is_enabled("file.reveal"), "disabled with no Project panel selection"); + s.execute("project.select", json!({"items": [items[0].0]})).unwrap(); + assert!(s.is_enabled("file.reveal")); + assert_eq!(s.execute("file.reveal", json!({})).unwrap()["paths"], json!([path.to_string_lossy()])); +} + #[test] fn edit_offline_sets_the_log_fields() { let mut s = demo(); diff --git a/crates/ui-egui/src/panels/menu_dialogs.rs b/crates/ui-egui/src/panels/menu_dialogs.rs index 1d35c21e8..91666e809 100644 --- a/crates/ui-egui/src/panels/menu_dialogs.rs +++ b/crates/ui-egui/src/panels/menu_dialogs.rs @@ -3,8 +3,8 @@ //! Update Metadata, Automate to Sequence, Scene Edit Detection, Normalize Mix Track, Simplify //! Sequence, Transcribe Sequence, Save as Template, Add Flash Cue Marker and the System //! Compatibility Report; plus the frontend side of Edit Original / Reveal Log Files (the OS -//! opener), Generate Audio Waveform (the timeline's peak cache), Import from Media Browser (the -//! Media Browser selection) and View ▸ Dynamic Audio Waveforms. +//! opener), Reveal in Finder, Generate Audio Waveform (the timeline's peak cache), Import from +//! Media Browser (the Media Browser selection) and View ▸ Dynamic Audio Waveforms. //! //! The open dialog lives in `UiState::extras.dialog` (serde, like `clip_dialogs`): the engine //! command it runs and the parameters being edited, so agents open it from the menu, change @@ -185,11 +185,12 @@ pub fn route(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: &Val app.ui.extras.dialog = Some(ClipDialogDraft { command: id.into(), params: json!({}), info: report.clone(), error: String::new() }); return Some(Ok(json!({"dialog": "systemReport", "report": report}))); } - "edit.editOriginal" => { + "edit.editOriginal" | "file.reveal" => { + let reveal = id == "file.reveal"; let r = app.session.execute(id, params.clone()).map_err(|e| e.to_string()); return Some(r.and_then(|v| { for p in v["paths"].as_array().cloned().unwrap_or_default() { - open_path(app, ctx, p.as_str().unwrap_or_default(), false)?; + open_path(app, ctx, p.as_str().unwrap_or_default(), reveal)?; } Ok(v) })); diff --git a/crates/ui-egui/src/panels/project_views.rs b/crates/ui-egui/src/panels/project_views.rs index e48d98dd4..22e90a860 100644 --- a/crates/ui-egui/src/panels/project_views.rs +++ b/crates/ui-egui/src/panels/project_views.rs @@ -262,6 +262,13 @@ fn item_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, id: ItemId, kind: &ItemK } } }); + let can_reveal = app.session.project.resolve_media(id).is_some_and(|(_, m, _)| matches!(m.media, filmcraft_project::MediaRef::File { .. })); + let b = ui.add_enabled(can_reveal, egui::Button::new(tl!("Reveal in Finder"))); + app.auto.add("project.itemMenu.file.reveal", b.rect, "Reveal in Finder"); + if b.clicked() { + actions.push(("file.reveal".into(), json!({"items": [id.0]}))); + ui.close(); + } if matches!(kind, ItemKind::Media(m) if matches!(m.media, filmcraft_project::MediaRef::File { .. })) { ui.separator(); for (label, cmd) in [(tl!("Link Media…"), "media.linkMedia"), (tl!("Make Offline…"), "media.makeOffline")] { diff --git a/crates/ui-egui/tests/menus_ui.rs b/crates/ui-egui/tests/menus_ui.rs index 55c42268c..b3354b2d6 100644 --- a/crates/ui-egui/tests/menus_ui.rs +++ b/crates/ui-egui/tests/menus_ui.rs @@ -305,3 +305,20 @@ fn color_matte_dialog_picks_and_changes_the_color() { assert_eq!(d.call("ui.menu.invoke", json!({"id": "project.matteColor"}))["ok"], json!(false)); assert!(!d.dialog_open()); } + +#[test] +fn reveal_in_finder_opens_an_imported_clip() { + let dir = std::env::temp_dir().join(format!("fc-ui-reveal-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("clip.png"); + image::RgbaImage::from_pixel(8, 8, image::Rgba([10, 20, 30, 255])).save(&path).unwrap(); + let mut d = Driver::new(); + let item = d.exec("file.import", json!({"paths": [path.to_string_lossy()]}))["items"][0].as_u64().unwrap(); + assert!(!d.app().session.is_enabled("file.reveal")); + d.exec("project.select", json!({"items": [item]})); + assert!(d.app().session.is_enabled("file.reveal")); + let r = d.ok("ui.menu.invoke", json!({"id": "file.reveal"})); + assert_eq!(r["paths"][0], path.to_string_lossy().as_ref()); + assert_eq!(d.app().ui.extras.opened.last().map(String::as_str), Some(path.to_string_lossy().as_ref())); + let _ = std::fs::remove_dir_all(&dir); +} From ba4dacdeffd35c09f4db52e694de9510680c09f7 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 13:05:18 +1100 Subject: [PATCH 171/260] #887: coalesce Scale (and similar) hot-text drags into one undo A drag of Effect Controls / Properties numbers already used merge+begin, but the press-start memory was only written on frames that emitted setParam. egui drops unused temp data after a quiet frame, so a Scale scrub that paused between percents began a new undo step for every 1%. Refresh the drag session on idle frames while the button is down; still begin a new step on the next press. Same keep-alive for graphics property drags. --- crates/ui-egui/src/panels/effect_controls.rs | 53 ++++++++++++++++++-- crates/ui-egui/src/panels/graphics.rs | 18 ++++--- 2 files changed, 62 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 6b071c4ca..5fa060726 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -963,21 +963,29 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { /// Run the panel's actions. Parameter changes made while the mouse button is down (a drag) share /// one undo step, which the first change of each press begins (#201); typed values and clicks stay /// separate steps. A drag value only changes once the mouse moves, after the press frame, so the -/// press that already began a step is remembered by its start time. +/// press that already began a step is remembered by its start time. That memory is refreshed on +/// every frame the button stays down — including frames with no value change — so a Scale scrub +/// that pauses between percents is still one undo (#887). fn run(app: &mut FilmcraftApp, ctx: &egui::Context, actions: Vec<(String, Value)>) { let (down, press) = ctx.input(|i| (i.pointer.any_down(), i.pointer.press_start_time())); let key = egui::Id::new("effect-controls-drag-step"); + let begun = ctx.data(|d| d.get_temp::>(key)).flatten(); + let mut wrote = false; for (cmd, mut p) in actions { if cmd == "effects.setParam" && down { - let begun = ctx.data(|d| d.get_temp::>(key)).flatten(); p["merge"] = json!(true); p["begin"] = json!(begun != press); - ctx.data_mut(|d| d.insert_temp(key, press)); + wrote = true; } if let Err(e) = app.session.execute(&cmd, p) { app.ui.status = e.to_string(); } } + if down && (wrote || begun.is_some()) { + ctx.data_mut(|d| d.insert_temp(key, press)); + } else if !down { + ctx.data_mut(|d| d.remove::>(key)); + } } pub(crate) fn graph_key(clip: ClipId, idx: usize, pid: &str) -> String { @@ -1253,6 +1261,45 @@ mod drag_undo_tests { app.session.undo(); assert_eq!(opacity(&app), start, "and then the first"); } + + /// #887: a Scale hot-text drag that pauses between percents (no `setParam` that frame) is + /// still one undo, not one step per 1%. + #[test] + fn a_scale_drag_with_idle_frames_is_one_undo_step() { + let mut s = filmcraft_engine::Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + let clip = s.active_sequence().unwrap().video_tracks[0].items[0].id.0; + let mut app = crate::FilmcraftApp::new(s); + let ctx = egui::Context::default(); + let scale = |app: &crate::FilmcraftApp| { + let q = app.session.active_sequence().unwrap(); + let it = q.find_item(filmcraft_project::ClipId(clip)).unwrap().1; + it.effects.iter().find(|e| e.effect == "motion").unwrap().params["scale"].value.as_f64().unwrap() + }; + let start = scale(&app); + let pos = egui::pos2(10.0, 10.0); + let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let mut time = 0.0; + let mut frame = |app: &mut crate::FilmcraftApp, events: Vec, value: Option| { + time += 0.1; + let raw = egui::RawInput { time: Some(time), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + let actions = value.map(|v| ("effects.setParam".to_string(), json!({"clip": clip, "effect": "motion", "param": "scale", "value": v}))); + super::run(app, ui.ctx(), actions.into_iter().collect()); + }); + out.textures_delta.clear(); + }; + frame(&mut app, vec![egui::Event::PointerMoved(pos), button(true)], None); + for v in [101.0, 150.0, 200.0, 249.0] { + frame(&mut app, vec![egui::Event::PointerMoved(pos)], Some(v)); + frame(&mut app, vec![egui::Event::PointerMoved(pos)], None); // idle: mouse down, no 1% tick + } + frame(&mut app, vec![button(false)], None); + assert_eq!(scale(&app), 249.0); + app.session.undo(); + assert_eq!(scale(&app), start, "one undo restores the scale from before the drag"); + assert_eq!(app.session.undo(), None, "the drag must not have left extra 1% steps"); + } } #[cfg(test)] diff --git a/crates/ui-egui/src/panels/graphics.rs b/crates/ui-egui/src/panels/graphics.rs index 7cf5f47b3..0de7063a1 100644 --- a/crates/ui-egui/src/panels/graphics.rs +++ b/crates/ui-egui/src/panels/graphics.rs @@ -1771,21 +1771,27 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { for (id, r, label) in autos { app.auto.add(&id, r, &label); } + let (down, press) = ui.ctx().input(|i| (i.pointer.any_down(), i.pointer.press_start_time())); + let key = egui::Id::new("gfx-props-drag-step"); + let begun = ui.ctx().data(|d| d.get_temp::>(key)).flatten(); + let mut wrote = false; for (cmd, mut p) in actions { // a drag of a property is one undo step: the first change of each press begins a new one, - // the rest fold into it (like Effect Controls, #201) - if cmd == "graphics.set" && ui.ctx().input(|i| i.pointer.any_down()) { - let key = egui::Id::new("gfx-props-drag-step"); - let press = ui.ctx().input(|i| i.pointer.press_start_time()); - let begun = ui.ctx().data(|d| d.get_temp::>(key)).flatten(); + // the rest fold into it (like Effect Controls, #201 / #887) + if cmd == "graphics.set" && down { p["merge"] = json!(true); p["begin"] = json!(begun != press); - ui.ctx().data_mut(|d| d.insert_temp(key, press)); + wrote = true; } if let Err(e) = app.session.execute(&cmd, p) { app.ui.status = e.to_string(); } } + if down && (wrote || begun.is_some()) { + ui.ctx().data_mut(|d| d.insert_temp(key, press)); + } else if !down { + ui.ctx().data_mut(|d| d.remove::>(key)); + } } /// Text Properties (the wrench in the Text section): Text Layer Type and Text Styling. From 8d62b7fe1a883d39168cb681f1a4edf34a35f604 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 14:03:25 +1100 Subject: [PATCH 172/260] Keep recovery snapshots when session.json is missing or corrupt. Startup scan used to delete an unlocked recovery session whenever session metadata could not be parsed, even if snapshot.fcproj was intact. Offer that snapshot for recovery instead of removing it. --- crates/engine/src/autosave.rs | 17 +++++++++-------- crates/engine/src/autosave_tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs index f5e8e809a..8af0fef11 100644 --- a/crates/engine/src/autosave.rs +++ b/crates/engine/src/autosave.rs @@ -572,15 +572,16 @@ pub fn scan_candidates(data_dir: &Path, skip: Option<&Path>) -> Vec(&b).ok()); - match meta { - Some(meta) if dir.join(SNAPSHOT).is_file() => { - out.push(RecoveryCandidate { id: e.file_name().to_string_lossy().into_owned(), dir, meta }); - } - _ => { - let _ = fs::remove_dir_all(&dir); - } + let snap = dir.join(SNAPSHOT); + if !snap.is_file() { + // Empty leftover dirs (clean exit, or a crash before the first snapshot). + let _ = fs::remove_dir_all(&dir); + continue; } + // Keep the snapshot even when session.json is missing or unreadable: the project + // is still recoverable. Meta fields fall back to defaults. + let meta = fs::read(dir.join(META)).ok().and_then(|b| serde_json::from_slice::(&b).ok()).unwrap_or_default(); + out.push(RecoveryCandidate { id: e.file_name().to_string_lossy().into_owned(), dir, meta }); } out.sort_by(|a, b| b.meta.saved_unix.cmp(&a.meta.saved_unix).then(b.id.cmp(&a.id))); out diff --git a/crates/engine/src/autosave_tests.rs b/crates/engine/src/autosave_tests.rs index 5ac8281e9..44569295c 100644 --- a/crates/engine/src/autosave_tests.rs +++ b/crates/engine/src/autosave_tests.rs @@ -176,6 +176,28 @@ fn auto_save_ring_rotates_and_can_save_the_project() { let _ = std::fs::remove_dir_all(&d); } +#[test] +fn scan_keeps_snapshot_when_session_metadata_is_unreadable() { + // #894: a stale session with a snapshot must not be deleted because session.json is missing + // or corrupt — the recovered project would vanish from the list and the file would be gone. + let d = temp_dir("recover-nometa"); + let root = autosave::recovery_root(&d); + let sess = root.join("123-1"); + std::fs::create_dir_all(&sess).unwrap(); + let snap = filmcraft_format::encode(&filmcraft_project::Project::default(), false); + std::fs::write(sess.join("snapshot.fcproj"), snap).unwrap(); + std::fs::write(sess.join("session.json"), b"not json {").unwrap(); + let found = autosave::scan_candidates(&d, None); + assert_eq!(found.len(), 1); + assert_eq!(found[0].id, "123-1"); + assert!(sess.join("snapshot.fcproj").is_file(), "snapshot must survive the scan"); + std::fs::remove_file(sess.join("session.json")).unwrap(); + let found = autosave::scan_candidates(&d, None); + assert_eq!(found.len(), 1, "missing metadata still lists the snapshot"); + assert!(sess.join("snapshot.fcproj").is_file()); + let _ = std::fs::remove_dir_all(&d); +} + #[test] fn portable_marker_keeps_data_next_to_the_executable() { // #139: a portable install must not write to %APPDATA%. From 0a4be7b9685b3ec403767681469f5da24e2b6339 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 14:04:39 +1100 Subject: [PATCH 173/260] Clear the saved path when opening the demo project. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit file.openDemoProject left Session.path pointing at the previous .fcproj, so File ▸ Save overwrote that project with the demo. Match New Project: drop the path (and the associated preview dir) so Save requires Save As and the original file stays intact. --- crates/engine/src/commands.rs | 3 +++ crates/engine/src/file_tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 6609afddf..e0ab132b8 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -864,6 +864,9 @@ fn build() -> Vec { s.state.active_sequence = Some(seq); s.state.open_sequences = vec![seq]; s.state.playheads.insert(seq, FrameRate::FPS_23_976.tick_of(4 * 24 + 6)); + if s.path.take().is_some() { + s.previews.reset_temp(); + } s.revision += 1; s.saved_revision = s.revision; s.events.push(crate::Event::ProjectChanged { revision: s.revision }); diff --git a/crates/engine/src/file_tests.rs b/crates/engine/src/file_tests.rs index 00f2ea078..f474b268c 100644 --- a/crates/engine/src/file_tests.rs +++ b/crates/engine/src/file_tests.rs @@ -93,3 +93,25 @@ fn project_is_named_after_its_file() { assert_eq!(s.project.name, "Apollo 11 - Tranquility"); let _ = std::fs::remove_dir_all(&d); } + +/// Opening the demo after a saved project must not keep that path: Save would overwrite it. +#[test] +fn open_demo_project_clears_saved_path_so_save_does_not_overwrite() { + let d = temp_dir("demo-path"); + let original = d.join("original.fcproj").to_string_lossy().to_string(); + let mut s = Session::default(); + s.execute("file.newProject", json!({"name": "Original"})).unwrap(); + s.execute("file.newBin", json!({"name": "ORIGINAL"})).unwrap(); + s.execute("file.saveAs", json!({"path": original})).unwrap(); + s.execute("file.openDemoProject", json!({})).unwrap(); + assert!(s.path.is_none(), "demo is unsaved, like New Project"); + let err = s.execute("file.save", json!({})).unwrap_err().to_string(); + assert!(err.contains("no path") || err.contains("Save As"), "{err}"); + let mut reopen = Session::default(); + reopen.execute("file.open", json!({"path": original})).unwrap(); + assert!( + reopen.project.root.children.iter().any(|e| matches!(e, filmcraft_project::BinEntry::Bin(b) if b.name == "ORIGINAL")), + "original.fcproj still has the ORIGINAL bin" + ); + let _ = std::fs::remove_dir_all(&d); +} From 7fef64de6b8e4c4e0be30fe3bb43be0910d7f711 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 14:09:05 +1100 Subject: [PATCH 174/260] #911: refuse timing edits on locked tracks Trim, roll, slip, slide, rate stretch and Speed/Duration now fail with "track is locked" instead of changing clips on a locked track, matching same-track move. Linked partners on locked tracks are left alone. --- crates/edit/src/lib.rs | 24 +++++- crates/edit/src/tests.rs | 11 +++ crates/engine/src/commands.rs | 78 +++++++++++++++--- crates/engine/src/lib.rs | 2 + crates/engine/src/locked_clip_edits_tests.rs | 84 ++++++++++++++++++++ 5 files changed, 188 insertions(+), 11 deletions(-) create mode 100644 crates/engine/src/locked_clip_edits_tests.rs diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index bb4e6415b..3846ea860 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -247,6 +247,15 @@ fn track_mut(seq: &mut Sequence, id: TrackId) -> Result<&mut Track> { seq.track_mut(id).ok_or(EditError::NoTrack(id)) } +/// The clip's track, or [`EditError::Locked`] when that track may not change. +fn unlocked_track(seq: &Sequence, clip: ClipId) -> Result { + let (tid, _) = seq.find_item(clip).ok_or(EditError::NoItem(clip))?; + if seq.track(tid).ok_or(EditError::NoTrack(tid))?.locked { + return Err(EditError::Locked); + } + Ok(tid) +} + /// Overwrite edits: each item replaces material on its track. pub fn overwrite(seq: &mut Sequence, placements: Vec<(TrackId, TrackItem)>, ctx: &mut EditCtx) -> Result> { let mut links = HashMap::new(); @@ -633,7 +642,8 @@ fn neighbours(track: &Track, id: ClipId) -> (Tick, Tick) { /// Compute the clamped delta a trim may apply (for UI feedback while dragging). pub fn clamp_trim(seq: &Sequence, clip: ClipId, edge: Edge, mode: TrimMode, delta: Tick, ctx: &EditCtx) -> Result { - let (tid, it) = seq.find_item(clip).ok_or(EditError::NoItem(clip))?; + let tid = unlocked_track(seq, clip)?; + let (_, it) = seq.find_item(clip).ok_or(EditError::NoItem(clip))?; let tr = seq.track(tid).ok_or(EditError::NoTrack(tid))?; let (prev_end, next_start) = neighbours(tr, clip); let media = media_len(ctx, it); @@ -703,6 +713,7 @@ pub fn move_edge(it: &mut TrackItem, edge: Edge, d: Tick, move_start: bool) { /// 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 { + unlocked_track(seq, clip)?; let d = clamp_trim(seq, clip, edge, mode, delta, ctx)?; if d == Tick::ZERO { return Ok(d); @@ -759,6 +770,9 @@ pub fn trim(seq: &mut Sequence, clip: ClipId, edge: Edge, mode: TrimMode, delta: /// overwrite material on a sync-locked track, or when such a linked clip has no room to follow. pub fn ripple_trim_group(seq: &mut Sequence, clips: &[ClipId], edge: Edge, delta: Tick, ctx: &mut EditCtx) -> Result { let Some(&first) = clips.first() else { return Ok(Tick::ZERO) }; + for c in clips { + unlocked_track(seq, *c)?; + } let mut d = delta; for c in clips { let x = clamp_trim(seq, *c, edge, TrimMode::Ripple, d, ctx)?; @@ -861,6 +875,8 @@ pub fn ripple_trim_group(seq: &mut Sequence, clips: &[ClipId], edge: Edge, delta /// Rolling edit between two adjacent items on one track: moves the cut by `delta`. pub fn roll(seq: &mut Sequence, left: ClipId, right: ClipId, delta: Tick, ctx: &mut EditCtx) -> Result { + unlocked_track(seq, left)?; + unlocked_track(seq, right)?; let (_, l) = seq.find_item(left).ok_or(EditError::NoItem(left))?; let (_, r) = seq.find_item(right).ok_or(EditError::NoItem(right))?; if l.end() != r.start { @@ -908,6 +924,7 @@ pub fn roll(seq: &mut Sequence, left: ClipId, right: ClipId, delta: Tick, ctx: & /// Slip: change which part of the media an item shows without moving it. pub fn slip(seq: &mut Sequence, clip: ClipId, delta_media: Tick, ctx: &mut EditCtx) -> Result { + unlocked_track(seq, clip)?; let (_, it) = seq.find_item(clip).ok_or(EditError::NoItem(clip))?; let used = src_of(it.duration, it.speed); let max_in = media_len(ctx, it).map(|m| m - used).unwrap_or(Tick::MAX); @@ -921,6 +938,7 @@ pub fn slip(seq: &mut Sequence, clip: ClipId, delta_media: Tick, ctx: &mut EditC /// Slide: move an item between its neighbours, trimming them to keep the gapless span. pub fn slide(seq: &mut Sequence, clip: ClipId, delta: Tick, ctx: &mut EditCtx) -> Result { + unlocked_track(seq, clip)?; let (tid, it) = seq.find_item(clip).ok_or(EditError::NoItem(clip))?; let it = it.clone(); let tr = seq.track(tid).ok_or(EditError::NoTrack(tid))?; @@ -1001,6 +1019,7 @@ pub fn slide_items(seq: &mut Sequence, clips: &[ClipId], delta: Tick, ctx: &mut /// 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 { + unlocked_track(seq, clip)?; let (tid, it) = seq.find_item(clip).ok_or(EditError::NoItem(clip))?; let tr = seq.track(tid).ok_or(EditError::NoTrack(tid))?; let (prev_end, next_start) = neighbours(tr, clip); @@ -1038,6 +1057,9 @@ pub fn set_speed_group(seq: &mut Sequence, clips: &[ClipId], speed: f64, reverse if !(speed.is_finite() && speed > 0.0) { return Err(EditError::Other("speed must be positive".into())); } + for clip in clips { + unlocked_track(seq, *clip)?; + } let mut work = seq.clone(); // per member track: where its later material starts, and by how much the member's length changed let mut origins: Vec<(TrackId, Tick, Tick)> = Vec::new(); diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 9272a94da..5b68abb4c 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -274,6 +274,17 @@ fn regular_trim_respects_neighbours_and_handles() { fx.seq.check().unwrap(); } +#[test] +fn trim_on_a_locked_track_is_refused() { + let mut fx = Fx::new(); + let v1 = fx.v(0); + let a = fx.put(v1, 0, 100, 0); + fx.seq.track_mut(v1).unwrap().locked = true; + let mut n = fx.next; + assert_eq!(trim(&mut fx.seq, a, Edge::Out, TrimMode::Regular, -f(20), &mut Fx::ctx(&mut n)), Err(EditError::Locked)); + assert_eq!(fx.spans(v1), vec![(0, 100)]); +} + #[test] fn held_regular_in_trim_stops_at_previous_clip() { let mut fx = Fx::new(); diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 6609afddf..3dbac87d2 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -397,16 +397,30 @@ fn move_clips(s: &mut Session, p: &Value) -> Result { /// 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; + let mut any = false; 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); + match edit::slip(&mut q.clone(), *c, *d, ctx) { + Err(edit::EditError::Locked) => continue, + Err(e) => return Err(e.into()), + Ok(x) => { + any = true; + scale = scale.min(x.0.abs() as f64 / d.0.abs() as f64); + } + } + } + if !any && deltas.iter().any(|(_, d)| d.0 != 0) { + return Err(edit::EditError::Locked.into()); } 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)?; + match edit::slip(q, *c, dd, ctx) { + Err(edit::EditError::Locked) => continue, + Err(e) => return Err(e.into()), + Ok(_) => {} + } } Ok(()) } @@ -432,12 +446,22 @@ pub fn with_links(s: &Session, clips: &[ClipId]) -> Vec { /// 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; + let mut any = false; for c in clips { - let x = edit::clamp_trim(q, *c, edge, mode, d, ctx)?; - if x.abs() < d.abs() { - d = x; + match edit::clamp_trim(q, *c, edge, mode, d, ctx) { + Err(edit::EditError::Locked) => continue, + Err(e) => return Err(e.into()), + Ok(x) => { + any = true; + if x.abs() < d.abs() { + d = x; + } + } } } + if !any { + return Err(edit::EditError::Locked.into()); + } Ok(d) } @@ -1462,7 +1486,12 @@ fn build() -> Vec { } } for group in &groups { - edit::set_speed_group(q, group, speed, reverse, ripple, ctx)?; + let unlocked: Vec = + group.iter().copied().filter(|c| q.find_item(*c).is_some_and(|(t, _)| q.track(t).is_some_and(|tr| !tr.locked))).collect(); + if unlocked.is_empty() { + return Err(edit::EditError::Locked.into()); + } + edit::set_speed_group(q, &unlocked, speed, reverse, ripple, ctx)?; } for c in &sel { if let (Some(m), Some((_, it))) = (interp, q.find_item_mut(*c)) { @@ -2302,6 +2331,9 @@ fn build() -> Vec { .unwrap_or_default(); let clips = with_links(s, &[c]); let applied = s.edit_sequence(if mode == TrimMode::Ripple { "Ripple Trim" } else { "Trim" }, |q, ctx, st| { + if q.find_item(c).is_some_and(|(t, _)| q.track(t).is_some_and(|tr| tr.locked)) { + return Err(edit::EditError::Locked.into()); + } let before = q.find_item(c).map(|(_, i)| i.range()); // clamp across all linked partners, then apply the common delta let mut dd = clamp_trim_linked(q, &clips, edge, mode, d, ctx)?; @@ -2309,6 +2341,10 @@ fn build() -> Vec { // the clip and its linked partners ripple as one edit let mut group = vec![c]; group.extend(clips.iter().copied().filter(|o| *o != c)); + group.retain(|id| q.find_item(*id).is_some_and(|(t, _)| q.track(t).is_some_and(|tr| !tr.locked))); + if group.is_empty() { + return Err(edit::EditError::Locked.into()); + } dd = edit::ripple_trim_group(q, &group, edge, dd, ctx)?; if st.ripple_sequence_markers && let Some(r) = before @@ -2322,7 +2358,11 @@ fn build() -> Vec { } } else { for c in &clips { - edit::trim(q, *c, edge, mode, dd, ctx)?; + match edit::trim(q, *c, edge, mode, dd, ctx) { + Err(edit::EditError::Locked) => continue, + Err(e) => return Err(e.into()), + Ok(_) => {} + } } } Ok(dd) @@ -2441,7 +2481,14 @@ fn build() -> Vec { .unwrap_or_default(); let clips = with_links(s, &[c]); // 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)?))?; + s.edit_sequence("Slide", |q, ctx, _| { + let unlocked: Vec = + clips.iter().copied().filter(|id| q.find_item(*id).is_some_and(|(t, _)| q.track(t).is_some_and(|tr| !tr.locked))).collect(); + if unlocked.is_empty() { + return Err(edit::EditError::Locked.into()); + } + Ok(edit::slide_items(q, &unlocked, d, ctx)?) + })?; Ok(Value::Null) }), cmd!("timeline.rateStretch", "Rate Stretch", [], None, r#"{"clip":id,"edge":"in|out","delta":ticks}"#, has_seq, |s, p| { @@ -2456,8 +2503,19 @@ fn build() -> Vec { let clips = with_links(s, &[c]); let sp = s.edit_sequence("Rate Stretch", |q, ctx, _| { let mut sp = 1.0; + let mut any = false; for c in &clips { - sp = edit::rate_stretch(q, *c, edge, d, ctx)?; + match edit::rate_stretch(q, *c, edge, d, ctx) { + Err(edit::EditError::Locked) => continue, + Err(e) => return Err(e.into()), + Ok(s) => { + any = true; + sp = s; + } + } + } + if !any { + return Err(edit::EditError::Locked.into()); } Ok(sp) })?; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a2ed9e6a2..079f047ae 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1114,6 +1114,8 @@ mod keyboard_tests; #[cfg(test)] mod linked_speed_tests; #[cfg(test)] +mod locked_clip_edits_tests; +#[cfg(test)] mod masks_tests; #[cfg(test)] mod media_browser_tests; diff --git a/crates/engine/src/locked_clip_edits_tests.rs b/crates/engine/src/locked_clip_edits_tests.rs new file mode 100644 index 000000000..67817a4dd --- /dev/null +++ b/crates/engine/src/locked_clip_edits_tests.rs @@ -0,0 +1,84 @@ +//! #911: a locked track refuses timing and source-range edits the way a locked destination +//! refuses `timeline.move`. + +use super::*; +use filmcraft_project::ClipId; +use serde_json::json; + +fn fixture() -> (Session, u64) { + let mut s = Session::default(); + s.execute("file.newProject", json!({"name": "Lock check"})).unwrap(); + s.execute("file.newSequence", json!({"name": "Sequence", "fps": 25, "video": 1, "audio": 0, "width": 16, "height": 16})).unwrap(); + s.execute("file.newOfflineFile", json!({"name": "Media", "seconds": 4, "fps": 25, "video": true, "audio": false, "width": 16, "height": 16})).unwrap(); + let item = s.project.items.values().find(|i| i.as_media().is_some()).map(|i| i.id.0).unwrap(); + s.execute("timeline.place", json!({"item": item, "track": "V1", "time": 0, "sourceIn": 0, "duration": 1_016_064_000_000i64})).unwrap(); + let clip = s.active_sequence().unwrap().video_tracks[0].items[0].id.0; + (s, clip) +} + +fn v1_clip(s: &Session, id: u64) -> filmcraft_project::TrackItem { + s.active_sequence().unwrap().find_item(ClipId(id)).unwrap().1.clone() +} + +/// Place a 100-frame clip, lock V1, trim Out by −20 frames: the command errors and duration stays 100. +#[test] +fn locked_track_refuses_trim_out_and_keeps_duration() { + let (mut s, clip) = fixture(); + let before = v1_clip(&s, clip); + assert_eq!(s.sequence_rate().frame_at(before.duration), 100); + s.execute("timeline.setTrack", json!({"track": "V1", "locked": true})).unwrap(); + let err = s.execute("timeline.trim", json!({"clip": clip, "edge": "out", "mode": "regular", "deltaFrames": -20})).unwrap_err(); + assert!(err.to_string().contains("locked"), "{err}"); + let after = v1_clip(&s, clip); + assert_eq!(after.duration, before.duration); + assert_eq!(after.start, before.start); + assert_eq!(after.source_in, before.source_in); + assert_eq!(after.speed, before.speed); +} + +#[test] +fn unlocked_track_still_trims_out() { + let (mut s, clip) = fixture(); + s.execute("timeline.trim", json!({"clip": clip, "edge": "out", "mode": "regular", "deltaFrames": -20})).unwrap(); + assert_eq!(s.sequence_rate().frame_at(v1_clip(&s, clip).duration), 80); +} + +#[test] +fn locked_track_refuses_move_on_the_same_track() { + let (mut s, clip) = fixture(); + let before = v1_clip(&s, clip); + s.execute("timeline.setTrack", json!({"track": "V1", "locked": true})).unwrap(); + let err = s.execute("timeline.move", json!({"moves": [{"clip": clip, "track": "V1", "time": 1_016_064_000_000i64}], "linked": false})).unwrap_err(); + assert!(err.to_string().contains("locked"), "{err}"); + assert_eq!(v1_clip(&s, clip).duration, before.duration); +} + +#[test] +fn locked_track_refuses_roll_slip_slide_speed() { + let mut s = Session::default(); + s.execute("file.newProject", json!({"name": "Lock check"})).unwrap(); + s.execute("file.newSequence", json!({"name": "Sequence", "fps": 25, "video": 1, "audio": 0, "width": 16, "height": 16})).unwrap(); + s.execute("file.newOfflineFile", json!({"name": "Media", "seconds": 4, "fps": 25, "video": true, "audio": false, "width": 16, "height": 16})).unwrap(); + let item = s.project.items.values().find(|i| i.as_media().is_some()).map(|i| i.id.0).unwrap(); + let frame = 10_160_640_000i64; + s.execute("timeline.place", json!({"item": item, "track": "V1", "time": 0, "sourceIn": frame * 10, "duration": frame * 60})).unwrap(); + s.execute("timeline.place", json!({"item": item, "track": "V1", "time": frame * 60, "sourceIn": 0, "duration": frame * 100})).unwrap(); + let (left, right) = { + let q = s.active_sequence().unwrap(); + (q.video_tracks[0].items[0].id.0, q.video_tracks[0].items[1].id.0) + }; + s.execute("timeline.setTrack", json!({"track": "V1", "locked": true})).unwrap(); + let snap = s.project.clone(); + for cmd in [ + json!({"id": "timeline.roll", "p": {"left": left, "right": right, "deltaFrames": 10}}), + json!({"id": "timeline.slip", "p": {"clip": left, "deltaFrames": -10}}), + json!({"id": "timeline.slide", "p": {"clip": left, "deltaFrames": 10}}), + json!({"id": "timeline.rateStretch", "p": {"clip": left, "edge": "out", "deltaFrames": 20}}), + json!({"id": "clip.speedDuration", "p": {"clips": [left], "speed": 200}}), + json!({"id": "timeline.trim", "p": {"clip": left, "edge": "in", "mode": "regular", "deltaFrames": 20}}), + ] { + let err = s.execute(cmd["id"].as_str().unwrap(), cmd["p"].clone()).unwrap_err(); + assert!(err.to_string().contains("locked"), "{}: {err}", cmd["id"]); + assert_eq!(*s.project, *snap, "{} must leave the sequence unchanged", cmd["id"]); + } +} From fbbb57d325483bab6ca532deae8e8a642283c83f Mon Sep 17 00:00:00 2001 From: jahvari <75337667+jahvari@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:50:23 -0500 Subject: [PATCH 175/260] WIN-02: Serialize shared codec test fixture publication Prevent concurrent fixture producers from replacing files held open by external decoders. Add a four-consumer Windows sharing regression. Co-Authored-By: Codex (GPT-6) --- crates/h264/tests/common/mod.rs | 6 ++++ crates/h264/tests/conformance.rs | 47 ++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/crates/h264/tests/common/mod.rs b/crates/h264/tests/common/mod.rs index ab75ea00e..e100c3289 100644 --- a/crates/h264/tests/common/mod.rs +++ b/crates/h264/tests/common/mod.rs @@ -4,6 +4,11 @@ use filmcraft_h264::{Decoder, Picture}; use std::path::{Path, PathBuf}; use std::process::Command; +use std::sync::{Mutex, PoisonError}; + +// Tests share fixture names within each binary. Serialize the existence check, +// generation and publication so a late producer cannot replace a file in use. +static FIXTURE_GENERATION: Mutex<()> = Mutex::new(()); /// One encoder configuration of the fixture matrix. pub struct Fixture { @@ -334,6 +339,7 @@ fn run(cmd: &mut Command) -> bool { /// Returns None (with a message) when ffmpeg is unavailable. pub fn ensure(f: &Fixture) -> Option<(PathBuf, PathBuf)> { let ff = filmcraft_testkit::ffmpeg_or_skip(f.name)?; + let _generation = FIXTURE_GENERATION.lock().unwrap_or_else(PoisonError::into_inner); let dir = fixtures_dir(); let h264 = dir.join(format!("{}.h264", f.name)); let yuv = dir.join(format!("{}.yuv", f.name)); diff --git a/crates/h264/tests/conformance.rs b/crates/h264/tests/conformance.rs index 154bb8e95..c06694b79 100644 --- a/crates/h264/tests/conformance.rs +++ b/crates/h264/tests/conformance.rs @@ -148,3 +148,50 @@ fn generate_fixtures() { filmcraft_testkit::fixtures::generate_and_report(&format!("h264/{}", f.name), &outs, || common::ensure(f)); } } + +/// Concurrent consumers must publish one complete fixture without replacing an open file. +#[test] +fn concurrent_fixture_publication_is_safe() { + if filmcraft_testkit::ffmpeg_or_skip("concurrent_fixture_publication").is_none() { + return; + } + let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let name = Box::leak(format!("concurrent_fixture_{}_{stamp}", std::process::id()).into_boxed_str()); + let fixture = + common::Fixture { name, source: "testsrc2", width: 64, height: 48, frames: 2, filter: "", args: &["-preset", "ultrafast", "-x264-params", "keyint=1"] }; + let open_published = |path: &std::path::Path| { + let mut options = std::fs::OpenOptions::new(); + options.read(true); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + // Like the external decoder, allow reads but deny replacement while open. + options.share_mode(1); + } + options.open(path).unwrap() + }; + let start = std::sync::Barrier::new(4); + let results = std::thread::scope(|scope| { + let jobs: Vec<_> = (0..4) + .map(|_| { + scope.spawn(|| { + start.wait(); + let paths = common::ensure(&fixture).expect("software fixture must generate"); + // Keep both published files open until every producer has finished. + let files = (open_published(&paths.0), open_published(&paths.1)); + (paths, files) + }) + }) + .collect(); + jobs.into_iter().map(|j| j.join().expect("fixture producer panicked")).collect::>() + }); + let paths = results[0].0.clone(); + for (got, _) in &results { + assert_eq!(got, &paths); + } + assert!(!std::fs::read(&paths.0).unwrap().is_empty()); + assert_eq!(std::fs::metadata(&paths.1).unwrap().len(), 2 * (64 * 48 + 2 * 32 * 24)); + drop(results); + std::fs::remove_file(paths.0).unwrap(); + std::fs::remove_file(paths.1).unwrap(); +} From 61df55ff43080c4d0bf218e50e343b5c6c4c2edb Mon Sep 17 00:00:00 2001 From: jahvari <75337667+jahvari@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:42:02 -0500 Subject: [PATCH 176/260] WIN-02: Make parallel GOP cache test deterministic Co-Authored-By: Codex (GPT-6) --- crates/codecs/src/gop.rs | 34 +++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/crates/codecs/src/gop.rs b/crates/codecs/src/gop.rs index c1395e430..20865c779 100644 --- a/crates/codecs/src/gop.rs +++ b/crates/codecs/src/gop.rs @@ -919,19 +919,27 @@ mod tests { fn intra_frames_decode_in_parallel_once_each() { let s = Arc::new(samples(64, 1, 0, true)); let c = Arc::new(GopCache::new(None)); - std::thread::scope(|scope| { - for t in 0..4usize { - let (s, c) = (s.clone(), c.clone()); - scope.spawn(move || { - for k in 0..64usize { - let i = (k * 7 + t * 16) % 64; - assert_eq!(index_of(&c.frame(&*s, i as i64 * 1000).expect("frame")), i); - } - }); - } - }); - // a frame two threads miss at the same moment may decode twice; nothing more - assert!(s.decodes.load(Ordering::Relaxed) <= 64 + 4 * 4); + // First decode 64 distinct cold frames: each of the four workers owns + // 16 indices. Multiplication by seven permutes them without overlap. + // Then let every worker request all 64 cached frames in overlapping order. + // Concurrent misses for the same cold frame may legitimately decode twice, + // so they cannot support a scheduler-independent duplicate allowance. + for requests_per_worker in [16usize, 64] { + let start = Arc::new(std::sync::Barrier::new(4)); + std::thread::scope(|scope| { + for t in 0..4usize { + let (s, c, start) = (s.clone(), c.clone(), start.clone()); + scope.spawn(move || { + start.wait(); + for k in 0..requests_per_worker { + let i = (k * 7 + t * 16) % 64; + assert_eq!(index_of(&c.frame(&*s, i as i64 * 1000).expect("frame")), i); + } + }); + } + }); + assert_eq!(s.decodes.load(Ordering::Relaxed), 64, "cold frames decode once; warm requests hit the cache"); + } assert_eq!(s.resets.load(Ordering::Relaxed), 0); } From e60a7e43bf0eb67a288f220b95ec357f3655b74e Mon Sep 17 00:00:00 2001 From: jahvari <75337667+jahvari@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:29:21 -0500 Subject: [PATCH 177/260] WIN-03: Normalize packaging fixture line endings in tests --- xtask/src/main.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 2e1908687..08f1ade7d 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -519,7 +519,8 @@ mod tests { 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())) + // Git may check out packaging files with CRLF on Windows. + std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display())).replace("\r\n", "\n") } /// Voice-over recording needs both halves on macOS: the usage string (else the permission From e283f4dee7726599ca568bc20bb8a8881fa7daf8 Mon Sep 17 00:00:00 2001 From: Mara Johnson Date: Sat, 10 Oct 2026 23:57:14 -0400 Subject: [PATCH 178/260] fix(media): RF64 trailing metadata stays outside audio duration Use ds64.dataSize for an RF64 data-size sentinel instead of the rest of the file, so chunks after the audio are no longer decoded as samples. Fixes #925 --- crates/codecs/tests/rf64_metadata.rs | 69 ++++++++++++++++++++++++++++ crates/media/src/wav.rs | 14 +++++- 2 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 crates/codecs/tests/rf64_metadata.rs diff --git a/crates/codecs/tests/rf64_metadata.rs b/crates/codecs/tests/rf64_metadata.rs new file mode 100644 index 000000000..164c01d65 --- /dev/null +++ b/crates/codecs/tests/rf64_metadata.rs @@ -0,0 +1,69 @@ +//! #925: a chunk after an RF64 `data` sentinel must not become audio. + +use std::sync::Arc; + +use filmcraft_time::Tick; + +fn chunk(id: &[u8; 4], body: &[u8]) -> Vec { + let mut out = id.to_vec(); + out.extend_from_slice(&(body.len() as u32).to_le_bytes()); + out.extend_from_slice(body); + if body.len() % 2 == 1 { + out.push(0); + } + out +} + +/// Four 16-bit mono frames of silence at 48 kHz, as RIFF or RF64, optionally with a trailing iXML. +fn fixture(rf64: bool, trailing: bool) -> Vec { + let mut fmt = Vec::new(); + for v in [1u16, 1] { + fmt.extend_from_slice(&v.to_le_bytes()); + } + fmt.extend_from_slice(&48_000u32.to_le_bytes()); + fmt.extend_from_slice(&96_000u32.to_le_bytes()); + fmt.extend_from_slice(&2u16.to_le_bytes()); + fmt.extend_from_slice(&16u16.to_le_bytes()); + let mut out = if rf64 { b"RF64\xff\xff\xff\xffWAVE".to_vec() } else { b"RIFF\0\0\0\0WAVE".to_vec() }; + if rf64 { + let mut ds = vec![0u8; 28]; + ds[8..16].copy_from_slice(&8u64.to_le_bytes()); + ds[16..24].copy_from_slice(&4u64.to_le_bytes()); + out.extend(chunk(b"ds64", &ds)); + } + out.extend(chunk(b"fmt ", &fmt)); + let offset = out.len(); + out.extend(chunk(b"data", &[0u8; 8])); + if rf64 { + out[offset + 4..offset + 8].copy_from_slice(&u32::MAX.to_le_bytes()); + } + if trailing { + out.extend(chunk(b"iXML", b"")); + } + let size = out.len() - 8; + if rf64 { + out[20..28].copy_from_slice(&(size as u64).to_le_bytes()); + } else { + out[4..8].copy_from_slice(&(size as u32).to_le_bytes()); + } + out +} + +#[test] +fn rf64_metadata_stays_outside_audio() { + for (name, rf64, trailing) in [("rf64.wav", true, false), ("riff_ixml.wav", false, true), ("rf64_ixml.wav", true, true)] { + let bytes: Arc<[u8]> = fixture(rf64, trailing).into(); + let src = filmcraft_codecs::open_bytes(name, bytes.clone()).unwrap(); + let stream = filmcraft_media::reader::open_reader( + name, + Arc::new(filmcraft_media::reader::MemReader(bytes)), + &filmcraft_codecs::reader_openers(), + &filmcraft_codecs::openers(), + ) + .unwrap(); + for s in [&src, &stream] { + assert_eq!(s.info().duration, Tick::from_units(4, 48_000), "{name}"); + assert_eq!(s.audio(0, 20, 48_000).unwrap().channels.remove(0), vec![0.0; 20], "{name}"); + } + } +} diff --git a/crates/media/src/wav.rs b/crates/media/src/wav.rs index 4a0df3d2b..a15f0d6b2 100644 --- a/crates/media/src/wav.rs +++ b/crates/media/src/wav.rs @@ -62,6 +62,7 @@ impl WavSource { let mut data = None; let mut bext_tr = None; let mut ixml_tr = None; + let mut ds64_data = None; for _ in 0..MAX_CHUNKS { let Some(body) = pos.checked_add(8).filter(|&b| b <= len) else { break }; let mut hdr = [0u8; 8]; @@ -69,8 +70,12 @@ impl WavSource { 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 id == b"data" { + // RF64 sentinel: the real size is ds64.dataSize (EBU 3306 §3.4), else the rest of the file + if clen == 0xFFFF_FFFF { + clen = ds64_data.unwrap_or(rest); + } + clen = clen.min(rest); } if clen > rest { break; @@ -87,6 +92,11 @@ impl WavSource { } fmt = Some((tag, ch, rate, bits)); } + // ds64: riffSize u64, dataSize u64, sampleCount u64, ... + b"ds64" if clen >= 16 => { + let b = read_range(&*reader, body + 8, 8).map_err(io)?; + ds64_data = Some(u64::from(le32(&b, 0)) | u64::from(le32(&b, 4)) << 32); + } b"data" => data = Some((body, clen)), // bext: Description 256, Originator 32, OriginatorReference 32, OriginationDate 10, // OriginationTime 8, then TimeReference (low u32, high u32) From c6ced890151a3ca4fca8b7769fcd7ebeb0bba4b8 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 10:59:58 +0700 Subject: [PATCH 179/260] tts-text: preserve leading zeros, fraction operands and ordinal magnitude --- crates/tts-text/src/normalize.rs | 89 +++++++++++++++++++++++++++++--- 1 file changed, 81 insertions(+), 8 deletions(-) diff --git a/crates/tts-text/src/normalize.rs b/crates/tts-text/src/normalize.rs index 235d8423a..037d86334 100644 --- a/crates/tts-text/src/normalize.rs +++ b/crates/tts-text/src/normalize.rs @@ -523,11 +523,11 @@ fn english_month(month: u64) -> Option<&'static str> { /// `1/2` → "one half"; other small denominators read "N ordinal(s)", anything /// bigger reads "N over M". fn parse_fraction(rest: &str) -> Option<(Vec, usize)> { - let (n, n_end) = read_u64(rest, 0, 2)?; + let (n, n_end) = read_u64(rest, 0, MAX_CARDINAL_DIGITS)?; if peek(rest, n_end) != Some('/') || n == 0 { return None; } - let (d, d_end) = read_u64(rest, n_end + 1, 2)?; + let (d, d_end) = read_u64(rest, n_end + 1, MAX_CARDINAL_DIGITS)?; if d == 0 || peek(rest, d_end).is_some_and(|c| c.is_ascii_alphanumeric() || c == '/') { return None; } @@ -587,7 +587,7 @@ fn parse_number_core(rest: &str, lang: Lang) -> Option<(Vec, usize)> { return Some((words, used)); } let value: u64 = digits.parse().ok()?; - if let Some((tokens, u)) = parse_continuation(rest, used, value) { + if let Some((tokens, u)) = parse_continuation(rest, used, value, &digits) { return Some((tokens, u)); } if has_leading_zero_str(&digits) { @@ -613,10 +613,20 @@ fn percent_suffix_len(rest: &str) -> Option { suffix.strip_prefix('%').map(|_| rest.len() - suffix.len() + 1) } -fn parse_continuation(rest: &str, used: usize, value: u64) -> Option<(Vec, usize)> { +/// Retain digit-by-digit reading of zero-prefixed integer portions, even when +/// followed by a percent sign or decimal fraction. +fn spoken_integer(value: u64, digits: &str) -> Vec { + if has_leading_zero_str(digits) { + digits.chars().map(|c| english_digit_word(c).into()).collect() + } else { + english_cardinal(value) + } +} + +fn parse_continuation(rest: &str, used: usize, value: u64, digits: &str) -> Option<(Vec, usize)> { let after = rest.get(used..)?; if let Some(suffix_len) = percent_suffix_len(after) { - let mut words = english_cardinal(value); + let mut words = spoken_integer(value, digits); words.push("percent".into()); return Some((words.into_iter().map(Token::Word).collect(), used + suffix_len)); } @@ -629,7 +639,7 @@ fn parse_continuation(rest: &str, used: usize, value: u64) -> Option<(Vec None } Some('.') if peek(after, 1).is_some_and(|c| c.is_ascii_digit()) => { - let mut words = english_cardinal(value); + let mut words = spoken_integer(value, digits); words.push("point".into()); let (frac, frac_used) = digit_run_as_words(after.get(1..)?); if frac.is_empty() { @@ -875,8 +885,8 @@ fn english_ordinal(value: u64) -> String { "nine" => "ninth", "twelve" => "twelfth", t if t.ends_with('y') => { - let stem = t.get(..t.len() - 1).unwrap_or(t); - return format!("{prefix}{stem}ieth"); + let root = t.get(..t.len() - 1).unwrap_or(t); + return attach(stem, &format!("{prefix}{root}ieth")); } t => return attach(stem, &format!("{prefix}{t}th")), }; @@ -1182,6 +1192,28 @@ mod tests { assert_eq!(words("101st", Lang::EnUs), "one hundred first"); } + #[test] + fn magnitude_is_kept_before_exact_tens_ordinals() { + for lang in [Lang::EnUs, Lang::EnGb] { + for (input, expected) in [ + ("20th", "twentieth"), + ("100th", "one hundredth"), + ("101st", "one hundred first"), + ("119th", "one hundred nineteenth"), + ("120th", "one hundred twentieth"), + ("130th", "one hundred thirtieth"), + ("150th", "one hundred fiftieth"), + ("190th", "one hundred ninetieth"), + ("1,020th", "one thousand twentieth"), + ("1120th", "one thousand one hundred twentieth"), + ("121st", "one hundred twenty-first"), + ("200th", "two hundredth"), + ] { + assert_eq!(words(input, lang), expected, "{input:?} {lang:?}"); + } + } + } + #[test] fn money() { assert_eq!(words("$3.50", Lang::EnUs), "three dollars and fifty cents"); @@ -1338,6 +1370,47 @@ mod tests { assert_eq!(words("7/32", Lang::EnUs), "seven over thirty-two"); } + #[test] + fn fraction_operands_may_have_three_digits() { + for lang in [Lang::EnUs, Lang::EnGb] { + for (input, expected) in [ + ("7/100", "seven over one hundred"), + ("100/7", "one hundred sevenths"), + ("7/99", "seven over ninety-nine"), + ("99/7", "ninety-nine sevenths"), + ("7/0", "seven slash zero"), + ("1/2", "one half"), + ("3/4", "three quarters"), + ("22/7", "twenty-two sevenths"), + ] { + assert_eq!(words(input, lang), expected, "{input:?} {lang:?}"); + } + } + assert_eq!(words("10/7/2026", Lang::EnUs), "October seventh twenty twenty-six"); + assert_eq!(words("7/10/2026", Lang::EnGb), "the seventh of October twenty twenty-six"); + } + + #[test] + fn percent_and_decimal_suffixes_keep_leading_zeroes() { + for lang in [Lang::EnUs, Lang::EnGb] { + for (input, expected) in [ + ("07", "zero seven"), + ("007", "zero zero seven"), + ("07%", "zero seven percent"), + ("007%", "zero zero seven percent"), + ("007 %", "zero zero seven percent"), + ("07.5", "zero seven point five"), + ("007.5", "zero zero seven point five"), + ("007.5%", "zero zero seven point five percent"), + ("20%", "twenty percent"), + ("0.5", "zero point five"), + ("0.5%", "zero point five percent"), + ] { + assert_eq!(words(input, lang), expected, "{input:?} {lang:?}"); + } + } + } + #[test] fn pause_markers() { let tokens = normalize("[pause 1s]", Lang::EnUs).unwrap(); From b13567439b40b6d1ef7389dd22ec12f2a1733683 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 15:01:54 +1100 Subject: [PATCH 180/260] Fix FCPXML one-frame markers importing as ranges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FCPXML writes point markers as one frame at the sequence rate. Import compared duration to 1 tick, so a 24fps one-frame marker became a range. Treat duration ≤ one sequence frame as a FilmCraft point. --- crates/interchange/src/fcpxml.rs | 10 +-- crates/interchange/tests/fcpxml.rs | 5 +- crates/interchange/tests/point_markers.rs | 86 +++++++++++++++++++++++ 3 files changed, 96 insertions(+), 5 deletions(-) create mode 100644 crates/interchange/tests/point_markers.rs diff --git a/crates/interchange/src/fcpxml.rs b/crates/interchange/src/fcpxml.rs index faf6a77cf..e231fff5e 100644 --- a/crates/interchange/src/fcpxml.rs +++ b/crates/interchange/src/fcpxml.rs @@ -431,7 +431,7 @@ impl<'a, 'i> Imp<'a, 'i, '_> { } self.adjustments(&mut ti, el, local_start, src_in, speed, ctx, kind); if !primary || kind == TrackKind::Audio { - ti.markers = self.markers(el, local_start, src_in, speed); + ti.markers = self.markers(el, local_start, src_in, speed, ctx.seq_rate); } let pref = match kind { TrackKind::Video => lane.max(0) as usize, @@ -453,7 +453,7 @@ impl<'a, 'i> Imp<'a, 'i, '_> { } if primary { // Markers on primary storyline elements are timeline markers. - for m in self.markers(el, local_start, Tick::ZERO, 1.0) { + for m in self.markers(el, local_start, Tick::ZERO, 1.0, ctx.seq_rate) { seq.markers.push(Marker { start: t0 + m.start, ..m }); } } @@ -475,8 +475,10 @@ impl<'a, 'i> Imp<'a, 'i, '_> { (t0 + dur, vclip) } - fn markers(&mut self, el: Node, local_start: Tick, src_in: Tick, speed: f64) -> Vec { + fn markers(&mut self, el: Node, local_start: Tick, src_in: Tick, speed: f64, rate: FrameRate) -> Vec { let mut out = Vec::new(); + // FCPXML has no zero-duration marker: a point is written as one frame at the sequence rate. + let frame = rate.frame_duration(); for m in elements(el).filter(|m| matches!(m.tag_name().name(), "marker" | "chapter-marker")) { let rel = attr_time(m, "start").unwrap_or(Tick::ZERO) - local_start; let start = if src_in == Tick::ZERO && speed == 1.0 { rel } else { src_in + scaled(rel, speed) }; @@ -484,7 +486,7 @@ impl<'a, 'i> Imp<'a, 'i, '_> { out.push(Marker { id: MarkerId(self.b.alloc()), start, - duration: if d.0 <= 1 { Tick::ZERO } else { d }, + duration: if d <= frame { Tick::ZERO } else { d }, name: m.attribute("value").unwrap_or("").to_string(), comment: m.attribute("note").unwrap_or("").to_string(), kind: if m.has_tag_name("chapter-marker") { MarkerKind::Chapter } else { MarkerKind::Comment }, diff --git a/crates/interchange/tests/fcpxml.rs b/crates/interchange/tests/fcpxml.rs index b5483450b..12892da57 100644 --- a/crates/interchange/tests/fcpxml.rs +++ b/crates/interchange/tests/fcpxml.rs @@ -152,7 +152,10 @@ fn golden_connected_clips() { assert!(s.audio_tracks.iter().flat_map(|t| &t.items).all(|i| i.item != boats.item)); // marker on a primary element is a timeline marker assert_eq!(s.markers.len(), 1); - assert_eq!((s.markers[0].name.as_str(), s.markers[0].comment.as_str(), s.markers[0].start), ("Seagull", "nice", r.tick_of(24))); + assert_eq!( + (s.markers[0].name.as_str(), s.markers[0].comment.as_str(), s.markers[0].start, s.markers[0].duration), + ("Seagull", "nice", r.tick_of(24), filmcraft_time::Tick::ZERO) + ); // event became a bin assert!(p.root.children.iter().any(|c| matches!(c, filmcraft_project::BinEntry::Bin(b) if b.name == "Day 1"))); assert!(!rep.has_warnings(), "{rep}"); diff --git a/crates/interchange/tests/point_markers.rs b/crates/interchange/tests/point_markers.rs new file mode 100644 index 000000000..e0ffb5616 --- /dev/null +++ b/crates/interchange/tests/point_markers.rs @@ -0,0 +1,86 @@ +//! FCPXML point markers: a one-frame `` is a FilmCraft point (duration 0), not a range. + +mod support; + +use filmcraft_interchange::{ExportOptions, Format, import}; +use filmcraft_project::{Label, Marker, MarkerId, MarkerKind, Project, TrackKind}; +use filmcraft_time::{FrameRate, Tick}; +use support::*; + +fn fcpxml_with_markers(frame: &str, point_dur: &str, range_dur: &str) -> String { + format!( + r#" + + + + + + + + + + + + + + + + + + + + + + + +"# + ) +} + +#[test] +fn one_frame_fcpxml_marker_is_a_point() { + let r = FrameRate::FPS_24; + let xml = fcpxml_with_markers("1/24s", "1/24s", "2/24s"); + let (imp, _) = import(xml.as_bytes(), Format::Fcpxml, None).unwrap(); + let s = imp.project.sequence(only_seq(&imp)).unwrap(); + assert_eq!(s.markers.len(), 2); + assert_eq!((s.markers[0].name.as_str(), s.markers[0].start, s.markers[0].duration), ("Point", r.tick_of(24), Tick::ZERO)); + assert_eq!((s.markers[1].name.as_str(), s.markers[1].start, s.markers[1].duration), ("Range", r.tick_of(48), r.tick_of(2))); +} + +#[test] +fn point_roundtrips_through_the_one_frame_convention() { + let r = FrameRate::FPS_24; + let mut p = Project::new("Lib"); + let a = media(&mut p, "/m/a.mov", true, true, r); + let s = sequence(&mut p, "Cut", r, false); + clip(&mut p, s, TrackKind::Video, 0, a, 0, 240, 0); + { + let q = p.sequence_mut(s).unwrap(); + q.markers.push(Marker { + id: MarkerId(1), + start: r.tick_of(24), + duration: Tick::ZERO, + name: "Point".into(), + comment: String::new(), + kind: MarkerKind::Comment, + color: Label::Blue, + }); + q.markers.push(Marker { + id: MarkerId(2), + start: r.tick_of(48), + duration: r.tick_of(5), + name: "Range".into(), + comment: "span".into(), + kind: MarkerKind::Comment, + color: Label::Blue, + }); + } + let (imp, text, _) = roundtrip(&p, s, Format::Fcpxml, &ExportOptions::default()); + assert!(text.contains("duration=\"1/24s\""), "{text}"); + let qs = imp.project.sequence(only_seq(&imp)).unwrap(); + assert_eq!( + qs.markers.iter().map(|m| (m.name.as_str(), m.start, m.duration)).collect::>(), + vec![("Point", r.tick_of(24), Tick::ZERO), ("Range", r.tick_of(48), r.tick_of(5))] + ); +} From 50f9cfecc04282bbf58dcb4ae0d0185f3a3e4a91 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 15:02:25 +1100 Subject: [PATCH 181/260] Fix OTIO nested sequence vs clip instance markers (#901) Export was overwriting Stack markers with the using clip's markers, so a nest with no instance markers lost its sequence markers, and instance markers were imported onto the nested sequence. Keep sequence markers on the Stack and store clip instance markers in metadata.filmcraft.clip. --- crates/interchange/src/otio.rs | 8 +- .../tests/nested_sequence_markers.rs | 77 +++++++++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 crates/interchange/tests/nested_sequence_markers.rs diff --git a/crates/interchange/src/otio.rs b/crates/interchange/src/otio.rs index b4fabb31f..421bea985 100644 --- a/crates/interchange/src/otio.rs +++ b/crates/interchange/src/otio.rs @@ -273,9 +273,10 @@ impl Exp<'_, '_> { let mut s = self.stack_json(base, &it.name, Some((it.source_in, it.duration, rate))); self.stack.pop(); s["effects"] = json!(effects); - s["markers"] = json!(markers); s["enabled"] = json!(it.enabled); s["metadata"]["filmcraft"]["clip"] = meta["filmcraft"].clone(); + // Nested sequence markers stay on the Stack; clip instance markers live on the clip metadata. + s["metadata"]["filmcraft"]["clip"]["markers"] = json!(markers); return s; } let _ = generator_of(self.p, base); @@ -665,7 +666,10 @@ impl Imp<'_, '_> { let name = name_of(c); let mut ti = self.b.clip(item, kind, if name.is_empty() { "Nested Sequence" } else { &name }, pos, dur, src_in); let clip_meta = meta.and_then(|m| m.get("clip")).cloned(); - // Stack markers belong to the nested sequence, not the clip instance. + // Stack markers belong to the nested sequence; instance markers are on filmcraft.clip. + if let Some(ref cm) = clip_meta { + ti.markers = self.markers(cm); + } self.apply_clip_meta(&mut ti, c, clip_meta.as_ref()); Some(ti) } diff --git a/crates/interchange/tests/nested_sequence_markers.rs b/crates/interchange/tests/nested_sequence_markers.rs new file mode 100644 index 000000000..9f69b755e --- /dev/null +++ b/crates/interchange/tests/nested_sequence_markers.rs @@ -0,0 +1,77 @@ +//! Nested sequence markers vs clip instance markers in OTIO (issue #901). +//! +//! FCP7 XML and FCPXML already keep these on the right objects. OTIO must not drop the nested +//! sequence's markers when the using clip has none, and must not put clip instance markers on the +//! nested sequence. + +mod support; + +use filmcraft_interchange::{ExportOptions, Format}; +use filmcraft_project::{Label, Marker, MarkerId, MarkerKind, TrackKind}; +use filmcraft_time::{FrameRate, Tick}; +use support::*; + +fn nest_project(clip_has_marker: bool) -> (filmcraft_project::Project, filmcraft_project::ItemId, filmcraft_project::ItemId) { + let r = FrameRate::FPS_24; + let mut p = filmcraft_project::Project::new("P"); + let a = media(&mut p, "/m/a.mov", true, true, r); + let nest = sequence(&mut p, "Inner", r, false); + clip(&mut p, nest, TrackKind::Video, 0, a, 0, 48, 0); + { + let q = p.sequence_mut(nest).unwrap(); + q.markers.push(Marker { + id: MarkerId(1), + start: r.tick_of(12), + duration: Tick::ZERO, + name: "on nest".into(), + comment: String::new(), + kind: MarkerKind::Comment, + color: Label::Green, + }); + } + let s = sequence(&mut p, "Outer", r, false); + let nest_clip = clip(&mut p, s, TrackKind::Video, 0, nest, 0, 48, 0); + if clip_has_marker { + let q = p.sequence_mut(s).unwrap(); + let (_, it) = q.find_item_mut(nest_clip).unwrap(); + it.markers.push(Marker { + id: MarkerId(2), + start: r.tick_of(6), + duration: Tick::ZERO, + name: "on clip".into(), + comment: String::new(), + kind: MarkerKind::Comment, + color: Label::Yellow, + }); + } + (p, s, nest) +} + +fn names(ms: &[Marker]) -> Vec<&str> { + ms.iter().map(|m| m.name.as_str()).collect() +} + +#[test] +fn otio_keeps_nested_sequence_markers_when_clip_has_none() { + let (p, s, nest) = nest_project(false); + let (imp, text, rep) = roundtrip(&p, s, Format::Otio, &ExportOptions::default()); + assert!(!rep.has_warnings(), "{rep}"); + let outer = only_seq(&imp); + let nest_clip = &imp.project.sequence(outer).unwrap().video_tracks[0].items[0]; + assert!(nest_clip.markers.is_empty(), "clip instance must stay marker-free\n{text}"); + let inner = nest_clip.item; + assert_eq!(imp.project.item(inner).unwrap().name, "Inner"); + assert_eq!(names(&imp.project.sequence(inner).unwrap().markers), names(&p.sequence(nest).unwrap().markers), "{text}"); +} + +#[test] +fn otio_keeps_clip_instance_markers_off_the_nested_sequence() { + let (p, s, nest) = nest_project(true); + let (imp, text, _) = roundtrip(&p, s, Format::Otio, &ExportOptions::default()); + let outer = only_seq(&imp); + let nest_clip = &imp.project.sequence(outer).unwrap().video_tracks[0].items[0]; + assert_eq!(names(&nest_clip.markers), vec!["on clip"], "{text}"); + assert_eq!(nest_clip.markers[0].start, FrameRate::FPS_24.tick_of(6)); + let inner = nest_clip.item; + assert_eq!(names(&imp.project.sequence(inner).unwrap().markers), names(&p.sequence(nest).unwrap().markers), "{text}"); +} From 3da5dec8d41efdf1cefd279026ce38ddd167ef30 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 15:03:07 +1100 Subject: [PATCH 182/260] Export FCP7 XML master-clip media markers (xmeml 4/5). Timeline clipitem markers already round-tripped; source-media annotations on master clips were omitted with an empty export report. Write those markers on so re-import keeps them. Fixes storytold/filmcraft#900. --- crates/interchange/src/fcp7.rs | 57 ++++++++++++++++++- .../interchange/tests/media_marker_export.rs | 49 ++++++++++++++++ 2 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 crates/interchange/tests/media_marker_export.rs diff --git a/crates/interchange/src/fcp7.rs b/crates/interchange/src/fcp7.rs index d5de669d2..962508a58 100644 --- a/crates/interchange/src/fcp7.rs +++ b/crates/interchange/src/fcp7.rs @@ -3,8 +3,9 @@ //! //! Supported: `project`/`bin`/`clip`/`sequence` structure, video and audio tracks (enabled, //! locked), `clipitem` (start/end/in/out, `pproTicksIn/Out` for tick-exact source times, enabled, -//! labels, markers, links), `file` (pathurl, rate, duration, timecode, media characteristics, -//! references by id), nested sequences (inline or by id), `generatoritem` (Slug, Color, …), +//! labels, markers, links), master-clip `` media markers, `file` (pathurl, rate, duration, +//! timecode, media characteristics, references by id), nested sequences (inline or by id), +//! `generatoritem` (Slug, Color, …), //! `transitionitem` (alignment, Cross Dissolve & friends), and the filters Basic Motion (scale, //! rotation, center, anchor, anti-flicker), Opacity, Time Remap (constant speed, reverse, freeze), //! Audio Levels and Audio Pan, all with keyframes. Other effects are written with our effect ids @@ -803,6 +804,7 @@ pub(crate) fn export(p: &Project, seq_id: ItemId, opts: &ExportOptions, report: }; let v = if opts.xmeml_version == 5 { "5" } else { "4" }; x.w.open("xmeml", &[("version", v)]); + x.write_master_clips(seq_id); x.write_sequence(seq_id, true); x.w.close(); Ok(x.w.finish()) @@ -951,6 +953,57 @@ impl Exp<'_, '_> { self.master_ids.entry(item).or_insert_with(|| format!("masterclip-{n}")).clone() } + /// Source-media (master clip) markers live on ``, not on timeline ``s. + fn write_master_clips(&mut self, seq_id: ItemId) { + let mut seen = HashSet::new(); + let mut items = Vec::new(); + self.collect_file_media(seq_id, &mut seen, &mut items); + for item in items { + let Some(m) = item_media(self.p, item) else { continue }; + if m.markers.is_empty() { + continue; + } + let rate = m + .info + .video + .as_ref() + .map(|v| m.interpret.frame_rate.unwrap_or(v.frame_rate)) + .unwrap_or_else(|| self.p.sequence(seq_id).map(|s| s.settings.frame_rate).unwrap_or_default()); + let name = self.p.item(item).map(|i| i.name.clone()).unwrap_or_default(); + let label = self.p.item(item).map(|i| i.label).unwrap_or(Label::Iris); + let duration = m.info.duration; + let markers = m.markers.clone(); + let mid = self.master_id(item); + self.w.open("clip", &[("id", &mid)]); + self.w.text("name", name); + self.w.text("duration", frames_round(rate, duration)); + write_rate(&mut self.w, rate); + self.w.open("labels", &[]); + self.w.text("label2", label.name()); + self.w.close(); + self.write_file(item, rate); + write_markers(&mut self.w, &markers, rate); + self.w.close(); + } + } + + fn collect_file_media(&self, seq: ItemId, seen: &mut HashSet, out: &mut Vec) { + if !seen.insert(seq) { + return; + } + let Some(s) = self.p.sequence(seq) else { return }; + for t in s.all_tracks() { + for c in &t.items { + let base = base_item(self.p, c.item); + match self.p.item(base).map(|i| &i.kind) { + Some(ItemKind::Sequence(_)) => self.collect_file_media(base, seen, out), + Some(ItemKind::Media(_)) if crate::common::generator_of(self.p, base).is_none() && !out.contains(&base) => out.push(base), + _ => {} + } + } + } + } + fn write_clip(&mut self, c: &TrackItem, kind: TrackKind, seq: &Sequence, xid: &str, links: &[(String, TrackKind, usize, usize)]) { let rate = seq.settings.frame_rate; if !on_frame(rate, c.start) || !on_frame(rate, c.duration) { diff --git a/crates/interchange/tests/media_marker_export.rs b/crates/interchange/tests/media_marker_export.rs new file mode 100644 index 000000000..752cc8b5d --- /dev/null +++ b/crates/interchange/tests/media_marker_export.rs @@ -0,0 +1,49 @@ +//! Source-media (master clip) markers must survive FCP7 XML export for xmeml 4 and 5. + +mod support; + +use filmcraft_interchange::{ExportOptions, Format, export, import}; +use filmcraft_project::{Label, Marker, MarkerId, MarkerKind, TrackKind}; +use filmcraft_time::{FrameRate, Tick}; +use support::*; + +fn project_with_media_marker(rate: FrameRate) -> (filmcraft_project::Project, filmcraft_project::ItemId) { + let mut p = filmcraft_project::Project::new("Markers"); + let a = media(&mut p, "/m/src.mov", true, true, rate); + if let Some(m) = p.item_mut(a).and_then(|i| i.as_media_mut()) { + m.markers.push(Marker { + id: MarkerId(1), + start: rate.tick_of(24), + duration: Tick::ZERO, + name: "media hit".into(), + comment: "on source".into(), + kind: MarkerKind::Comment, + color: Label::Green, + }); + } + let s = sequence(&mut p, "Cut", rate, false); + clip(&mut p, s, TrackKind::Video, 0, a, 0, 48, 0); + (p, s) +} + +#[test] +fn fcp7_xml_exports_master_clip_markers_on_xmeml_4_and_5() { + let rate = FrameRate::FPS_24; + let (p, s) = project_with_media_marker(rate); + for version in [4u32, 5] { + let opts = ExportOptions { xmeml_version: version, ..Default::default() }; + let (bytes, report) = export(&p, s, Format::Fcp7Xml, &opts).expect("export"); + let text = String::from_utf8(bytes.clone()).unwrap(); + assert!(text.contains(&format!("")), "{text}"); + assert!(text.contains("media hit"), "{text}"); + assert!(!report.has_warnings(), "export must not silently drop media markers: {report}"); + let (imp, irep) = import(&bytes, Format::Fcp7Xml, None).unwrap_or_else(|e| panic!("{e}\n{text}")); + assert!(!irep.has_warnings(), "{irep}"); + let media = imp.project.items.values().find(|i| i.name == "src.mov").and_then(|i| i.as_media()).expect("media"); + assert_eq!(media.markers.len(), 1, "{text}"); + assert_eq!(media.markers[0].name, "media hit"); + assert_eq!(media.markers[0].comment, "on source"); + assert_eq!(media.markers[0].start, rate.tick_of(24)); + } +} From eea3afa8beac34b38a60b31ad6bf0f3a58214b90 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 15:03:16 +1100 Subject: [PATCH 183/260] Essential Graphics: an Install refused shows why on the status bar (#611). MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Browse panel's Install… button calls the module's route directly and drops its result, while only menus.rs surfaced a refused file — so installing another application's template package (an EffectCraft .ectemplate ZIP renamed to .fcgt, the case in the report) looked like nothing happened. The engine has refused ZIP archives with a clear message since M10.7; the route now puts that message on the status bar itself, whichever caller arrives. Headless: clicking Install with the picker pointed at a ZIP-named file requires the status bar to name the ZIP archive; without the surfacing the status stays empty — the reporter's exact symptom. --- .../ui-egui/src/panels/graphics_templates.rs | 5 +++++ crates/ui-egui/tests/graphics_templates_ui.rs | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/crates/ui-egui/src/panels/graphics_templates.rs b/crates/ui-egui/src/panels/graphics_templates.rs index d66c346a8..27965808d 100644 --- a/crates/ui-egui/src/panels/graphics_templates.rs +++ b/crates/ui-egui/src/panels/graphics_templates.rs @@ -591,6 +591,11 @@ pub fn route(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: &Val "graphics.template.install" if params.get("path").is_none() => { let path = app.hooks.pick_open_file.as_mut().and_then(|f| f(tl!("FilmCraft Graphics Template"), &["fcgt"]))?; let r = app.session.execute(id, json!({"path": path})).map_err(|e| e.to_string()); + // The Browse panel's Install… button calls this directly and drops the result + // (#611); report a refused file here, so every caller shows why nothing happened. + if let Err(e) = &r { + app.ui.status = e.clone(); + } invalidate(ctx); Some(r) } diff --git a/crates/ui-egui/tests/graphics_templates_ui.rs b/crates/ui-egui/tests/graphics_templates_ui.rs index 57ffea712..7affec89b 100644 --- a/crates/ui-egui/tests/graphics_templates_ui.rs +++ b/crates/ui-egui/tests/graphics_templates_ui.rs @@ -203,3 +203,21 @@ fn type_tool_selection_styles_characters() { let hist = d.exec("history.list", json!({})); assert_eq!(hist["undo"].as_array().unwrap().last().unwrap(), "Character Style"); } + +/// #611: installing a file FilmCraft refuses (a ZIP — EffectCraft's template package renamed +/// to .fcgt) from the Browse panel must say why on the status bar, not do nothing. +#[test] +fn install_refused_shows_the_reason_on_the_status_bar() { + let mut d = Driver::demo("install-refused"); + let file = std::env::temp_dir().join(format!("filmcraft-gfx-ui-zip-{}.fcgt", std::process::id())); + std::fs::write(&file, [0x50u8, 0x4b, 0x03, 0x04]).unwrap(); + let picked = file.to_string_lossy().to_string(); + d.harness.state_mut().hooks.pick_open_file = Some(Box::new(move |_, _| Some(picked.clone()))); + d.ok("ui.set", json!({"workspace": "Captions and Graphics"})); + d.ok("ui.set", json!({"gfxTemplates": {"tab": "browse"}})); + d.ok("ui.click", json!({"id": "gfxTemplates.install"})); + let st = d.ok("ui.inspect", json!({})); + let _ = std::fs::remove_file(&file); + let msg = st["ui"]["status"].as_str().unwrap_or_default().to_lowercase(); + assert!(msg.contains("zip archive"), "the refused install left no visible reason: {:?}", st["ui"]["status"]); +} From a24e0055c6849e08eac88687963a978a23740154 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:04:16 +0700 Subject: [PATCH 184/260] ui: leave marked Program playback on its last included frame --- crates/ui-egui/src/lib.rs | 6 +- crates/ui-egui/src/menus.rs | 2 + crates/ui-egui/src/panels/keyboard.rs | 4 ++ .../ui-egui/tests/marked_playback_position.rs | 60 +++++++++++++++++++ 4 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/tests/marked_playback_position.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 438112a29..c3c841c06 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -224,6 +224,9 @@ pub struct Playback { pub hidden: bool, /// Forward playback stops here (Play In to Out, Play from Playhead to Out Point). pub stop_at: Option, + /// Resting playhead for an inclusive marked range; unlike stop_at, this + /// names the last visible frame, not the following playback boundary. + pub rest_at: Option, } /// How long (s) the audio clock may stand still during playback before the wall clock takes over. @@ -959,6 +962,7 @@ impl FilmcraftApp { pub fn stop(&mut self) { self.playback.playing = false; self.playback.stop_at = None; + self.playback.rest_at = None; self.playback.preroll = None; self.playback.meter.finish(); self.frames.stop_prefetch(); @@ -1079,7 +1083,7 @@ impl FilmcraftApp { (Tick::ZERO, dur) }; if let Some(end) = self.playback.stop_at.filter(|e| t >= *e && self.playback.speed > 0.0 && !self.playback.looping) { - self.session.set_playhead(end); + self.session.set_playhead(self.playback.rest_at.unwrap_or(end)); self.stop(); } else if t >= hi && self.playback.speed > 0.0 { if self.playback.looping { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index f48711505..5d006012f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -381,12 +381,14 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: } "playback.inToOut" => { let seq = app.session.active_sequence(); + let marked_out = seq.and_then(|q| q.mark_out); let out = seq.and_then(|q| q.mark_out.map(|o| o + q.settings.frame_rate.frame_duration())); if let Some(i) = seq.and_then(|q| q.mark_in) { app.session.set_playhead(i); } app.play(1.0); app.playback.stop_at = out; + app.playback.rest_at = marked_out; return Ok(Value::Null); } "playback.loop" => { diff --git a/crates/ui-egui/src/panels/keyboard.rs b/crates/ui-egui/src/panels/keyboard.rs index 0c69572b6..bdf744196 100644 --- a/crates/ui-egui/src/panels/keyboard.rs +++ b/crates/ui-egui/src/panels/keyboard.rs @@ -309,9 +309,13 @@ fn play_range(app: &mut FilmcraftApp, preroll: bool) -> Result { app.session.playhead() }; let stop = if preroll { end + filmcraft_time::Tick::from_seconds_f64(app.session.prefs.playback.postroll_seconds) } else { end }; + // Plain marked playback includes Out but stops after its frame. Extended + // preroll/postroll playback retains its existing boundary behavior. + let rest_at = if preroll { None } else { Some(seq.mark_out.unwrap_or((end - fd).max(filmcraft_time::Tick::ZERO))) }; app.session.set_playhead(start); app.play(1.0); app.playback.stop_at = Some(stop); + app.playback.rest_at = rest_at; Ok(json!({"from": start.0, "to": stop.0})) } diff --git a/crates/ui-egui/tests/marked_playback_position.rs b/crates/ui-egui/tests/marked_playback_position.rs new file mode 100644 index 000000000..1c78381d3 --- /dev/null +++ b/crates/ui-egui/tests/marked_playback_position.rs @@ -0,0 +1,60 @@ +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_time::Tick; +use filmcraft_ui_egui::{FilmcraftApp, menus}; +use serde_json::json; + +#[test] +fn program_range_playback_rests_on_inclusive_out_frame() { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).unwrap(); + let rate = session.sequence_rate(); + let out = rate.tick_of(24); + let fd = rate.frame_duration(); + session.execute("markers.markIn", json!({"time": 0})).unwrap(); + session.execute("markers.markOut", json!({"time": out.0})).unwrap(); + + let mut harness = Harness::builder() + .with_size(egui::vec2(1280.0, 800.0)) + .with_step_dt(0.25) + .build_eframe(move |_cc| FilmcraftApp::new(session)); + harness.step(); + let ctx = harness.ctx.clone(); + + for command in ["playback.inToOut", "playback.toOut"] { + harness.state_mut().session.set_playhead(Tick::ZERO); + menus::invoke(harness.state_mut(), &ctx, command, json!({"monitor": "program"})).unwrap(); + assert_eq!(harness.state().playback.stop_at, Some(out + fd)); + assert_eq!(harness.state().playback.rest_at, Some(out)); + for _ in 0..40 { + harness.step(); + if !harness.state().playback.playing { + break; + } + } + assert!(!harness.state().playback.playing, "{command} did not stop"); + assert_eq!(harness.state().session.playhead(), out, "{command} must rest on Out"); + assert_eq!(harness.state().playback.rest_at, None); + } + + // Ordinary end-of-sequence playback still rests on the last visible frame. + let duration = harness.state().session.active_sequence().unwrap().duration(); + harness.state_mut().session.set_playhead(duration - rate.tick_of(6)); + menus::invoke(harness.state_mut(), &ctx, "playback.forward", json!({"monitor": "program"})).unwrap(); + for _ in 0..40 { + harness.step(); + if !harness.state().playback.playing { + break; + } + } + assert!(!harness.state().playback.playing); + assert_eq!(harness.state().session.playhead(), duration - fd); + + // The deliberately extended preroll/postroll command is not snapped to Out. + menus::invoke(harness.state_mut(), &ctx, "playback.inToOutPreroll", json!({"monitor": "program"})).unwrap(); + let post = Tick::from_seconds_f64(harness.state().session.prefs.playback.postroll_seconds); + assert!(post > Tick::ZERO); + assert_eq!(harness.state().playback.stop_at, Some(out + fd + post)); + assert_eq!(harness.state().playback.rest_at, None); + harness.state_mut().stop(); +} From 4969601e9ac42958fe03e1175c91d236ac43bf0f Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:06:35 +0700 Subject: [PATCH 185/260] render: advance Noise grain from exact frame ticks --- crates/render/src/effects.rs | 9 ++++- crates/render/src/vfx/color.rs | 2 +- crates/render/tests/noise_frame_cadence.rs | 39 ++++++++++++++++++++++ 3 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 crates/render/tests/noise_frame_cadence.rs diff --git a/crates/render/src/effects.rs b/crates/render/src/effects.rs index f4eeb3ba0..b8dfce28b 100644 --- a/crates/render/src/effects.rs +++ b/crates/render/src/effects.rs @@ -77,6 +77,13 @@ pub(crate) fn dec(c: [f32; 3]) -> [f32; 3] { [srgb_to_linear(c[0].clamp(0.0, 1.0)), srgb_to_linear(c[1].clamp(0.0, 1.0)), srgb_to_linear(c[2].clamp(0.0, 1.0))] } +/// Stable seed for the *exact* frame timestamp. A fixed 30 Hz quantizer +/// would reuse the same noise field at 60/120 fps and can repeat at 30 fps +/// through floating-point rounding of seconds. +pub(crate) fn noise_frame_seed(cx: &FxCtx) -> u64 { + cx.t.0 as u64 +} + pub(crate) fn hash3(x: usize, y: usize, z: u64) -> f32 { let mut h = (x as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ (y as u64).wrapping_mul(0xC2B2_AE3D_27D4_EB4F) ^ z.wrapping_mul(0x1656_67B1_9E37_79F9); h ^= h >> 31; @@ -110,7 +117,7 @@ pub fn apply(img: &mut Image, e: &EffectInstance, cx: &FxCtx) { let amt = f(e, "amount", cx) / 100.0; let colored = b(e, "color"); let clip = b(e, "clip"); - let frame = (cx.seconds * 30.0) as u64; + let frame = noise_frame_seed(cx); let w = img.w; img.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| { for x in 0..w { diff --git a/crates/render/src/vfx/color.rs b/crates/render/src/vfx/color.rs index 309e6d729..5bff8ff82 100644 --- a/crates/render/src/vfx/color.rs +++ b/crates/render/src/vfx/color.rs @@ -337,7 +337,7 @@ pub fn noise(img: &mut Image, e: &EffectInstance, cx: &FxCtx) { let colored = bv(e, "color"); let clip = bv(e, "clip"); let grain = (fv(e, "grain_size", cx).max(1.0) * cx.px_scale).max(1.0); - let frame = (cx.seconds * 30.0) as u64; + let frame = crate::effects::noise_frame_seed(cx); let w = img.w; img.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| { for x in 0..w { diff --git a/crates/render/tests/noise_frame_cadence.rs b/crates/render/tests/noise_frame_cadence.rs new file mode 100644 index 000000000..57a6a7e18 --- /dev/null +++ b/crates/render/tests/noise_frame_cadence.rs @@ -0,0 +1,39 @@ +use filmcraft_project::{ParamValue, find_effect}; +use filmcraft_render::{Image, effects::{FxCtx, apply}}; +use filmcraft_time::{FrameRate, Tick}; + +fn render_noise(effect_id: &str, rate: FrameRate, frame: i64, amount: f64) -> Vec { + let mut image = Image::filled(16, 16, [0.5, 0.5, 0.5, 1.0]); + let mut effect = find_effect(effect_id).unwrap().instance(); + effect.param_mut("amount").unwrap().value = ParamValue::Float(amount); + let tick = rate.tick_of(frame); + let context = FxCtx { + t: tick, + px_scale: 1.0, + seconds: tick.seconds(), + timecode: "00:00:00:00", + clip_name: "generated matte", + project: None, + env: None, + working: filmcraft_color::WorkingSpace::Rec709, + }; + apply(&mut image, &effect, &context); + image.px +} + +#[test] +fn both_noise_variants_change_on_every_sequence_frame() { + for effect_id in ["noise", "noise_legacy"] { + for rate in [FrameRate::FPS_24, FrameRate::FPS_30, FrameRate::FPS_60, FrameRate::FPS_120] { + for frame in 0..5 { + let this = render_noise(effect_id, rate, frame, 100.0); + let next = render_noise(effect_id, rate, frame + 1, 100.0); + assert_ne!(this, next, "{effect_id} {rate:?} repeated frames {frame} and {}", frame + 1); + assert_eq!(this, render_noise(effect_id, rate, frame, 100.0), "same timestamp must be deterministic"); + } + } + // Floating-point second-to-30Hz conversion also repeated frames 122/123 at 30 fps. + assert_ne!(render_noise(effect_id, FrameRate::FPS_30, 122, 100.0), render_noise(effect_id, FrameRate::FPS_30, 123, 100.0)); + assert_eq!(render_noise(effect_id, FrameRate::FPS_60, 0, 0.0), vec![0.5, 0.5, 0.5, 1.0].repeat(16 * 16)); + } +} From 5d7dc800a86375e642edd3580d078367e1730cab Mon Sep 17 00:00:00 2001 From: Earl Co Date: Sun, 11 Oct 2026 06:09:02 +0200 Subject: [PATCH 186/260] M7: correct export loudness after peak limiting Bound post-limiter loudness correction and cover high-crest signals, infeasible targets, cancellation, and output alignment. Focused tests and lint pass; full workspace validation pending for draft review. --- crates/export/src/audio_out.rs | 58 +++++++++++++++++++-- crates/export/src/settings_tests.rs | 79 ++++++++++++++++++++++++++++- 2 files changed, 133 insertions(+), 4 deletions(-) diff --git a/crates/export/src/audio_out.rs b/crates/export/src/audio_out.rs index 5fd2b90c0..21ab56a89 100644 --- a/crates/export/src/audio_out.rs +++ b/crates/export/src/audio_out.rs @@ -1,6 +1,6 @@ //! The export audio path: the sequence mix at the output sample rate, folded to the output //! channel count, with Export ▸ Effects ▸ Loudness Normalization (a BS.1770-4 measuring pass over -//! the export range, then a linear gain and a look-ahead true-peak limiter). +//! the export range, then bounded gain refinement through a look-ahead true-peak limiter). //! //! The limiter's look-ahead delays its output; [`AudioOut`] mixes that many samples ahead (silence //! past the end of the range) and drops the first ones, so the exported audio stays aligned with @@ -20,6 +20,7 @@ use crate::{ExportError, ExportSettings, Result}; #[derive(Clone, Copy, Debug, Default, PartialEq, serde::Serialize)] pub struct LoudnessReport { pub measured_lufs: f64, + /// Applied gain before the limiter, including bounded loudness correction. pub gain_db: f64, } @@ -84,11 +85,62 @@ impl AudioOut { } let measured = meter.integrated(); let gain_db = filmcraft_audio_dsp::normalize_gain_db(measured, ln.target_lufs); - self.gain = 10f64.powf(gain_db / 20.0) as f32; - self.loudness = Some(LoudnessReport { measured_lufs: measured, gain_db }); + // Limiting changes integrated loudness. Measure the actual limited signal and + // refine its INPUT gain; the peak ceiling remains in force on every pass. + // Four trials and at most 9 dB of extra drive bound both work and compression. + let start = self.out; + let mut trial_gain = gain_db; + let mut best_gain = gain_db; + let mut best_error = f64::INFINITY; + let mut previous = None; + if measured.is_finite() && ln.target_lufs.is_finite() { + for _ in 0..4 { + self.rewind(start, trial_gain); + let mut limited = filmcraft_audio_dsp::LoudnessMeter::new(self.sr as f64, self.channels); + while self.out < self.end { + if cancel() { + self.rewind(start, best_gain); + return Err(ExportError::Cancelled); + } + let until = self.out.saturating_add(self.sr as i64).min(self.end); + if let Some(buf) = self.pull(until, sources) { + let refs: Vec<&[f32]> = buf.iter().map(Vec::as_slice).collect(); + limited.process(&refs); + } + } + let achieved = limited.integrated(); + if !achieved.is_finite() { + break; + } + let error = ln.target_lufs - achieved; + if error.abs() < best_error { + best_error = error.abs(); + best_gain = trial_gain; + } + // Stop on convergence or a ceiling-limited plateau. An impossible target + // must not cause unbounded passes or ever bypass the limiter. + if error.abs() <= 0.1 || previous.is_some_and(|last: f64| (achieved - last).abs() < 0.02) { + break; + } + previous = Some(achieved); + trial_gain += error.clamp(-3.0, 3.0); + } + } + self.rewind(start, best_gain); + self.loudness = Some(LoudnessReport { measured_lufs: measured, gain_db: best_gain }); Ok(()) } + /// Restore the output cursor and limiter history between measuring passes and delivery. + fn rewind(&mut self, start: i64, gain_db: f64) { + self.out = start; + self.next_in = start; + self.gain = 10f64.powf(gain_db / 20.0) as f32; + if let Some(limiter) = self.limiter.as_mut() { + limiter.reset(); + } + } + /// Mix `n` samples from `pos`, folded to the output channels (silence past the range end). /// Stereo output of a 5.1 Mix is the ITU-R BS.775 downmix; 5.1 output of a stereo Mix places it /// on L and R; 5.1 is in L, R, C, LFE, Ls, Rs order. diff --git a/crates/export/src/settings_tests.rs b/crates/export/src/settings_tests.rs index fb68289ec..e92ee6493 100644 --- a/crates/export/src/settings_tests.rs +++ b/crates/export/src/settings_tests.rs @@ -518,7 +518,7 @@ fn loudness_normalization_hits_target_and_true_peak() { assert!((i - target).abs() < 0.5, "target {target}: measured {i:.2} LUFS"); assert!(tp <= -0.9, "true peak {tp:.2} dBTP"); let rep = prog.loudness.lock().unwrap().unwrap(); - assert!((rep.measured_lufs - (target - rep.gain_db)).abs() < 1e-6); + assert!((rep.measured_lufs + rep.gain_db - target).abs() < 0.1, "unlimited tone needs no correction"); } // a target the peaks cannot reach: the limiter holds the true-peak ceiling let path = dir.path("hot.wav"); @@ -870,3 +870,80 @@ fn gpu_rendering_is_off_by_default() { let auto: ExportSettings = serde_json::from_value(serde_json::json!({"format": "h264", "gpuRendering": "auto"})).unwrap(); assert_eq!(auto.gpu_rendering, crate::GpuRendering::Auto); } + +/// Brief large peaks over a quiet bed exercise loudness lost during limiter release. +struct CrestSource(filmcraft_media::SharedSource, Arc); +impl MediaSource for CrestSource { + fn info(&self) -> &filmcraft_media::MediaInfo { + self.0.info() + } + fn video_frame(&self, req: FrameRequest) -> filmcraft_media::Result> { + self.0.video_frame(req) + } + fn audio(&self, start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result { + self.1.fetch_add(1, Ordering::Relaxed); + let samples: Vec = (0..frames) + .map(|i| { + let at = start + i as i64; + let phase = at.rem_euclid(sample_rate as i64 / 4); + let amplitude = if phase < sample_rate as i64 / 500 { 0.9 } else { 0.08 }; + amplitude * (std::f32::consts::TAU * 1000.0 * at as f32 / sample_rate as f32).sin() + }) + .collect(); + Ok(filmcraft_frame::AudioBuffer { sample_rate, channels: vec![samples.clone(), samples] }) + } +} + +#[test] +fn loudness_normalization_corrects_limiter_loss_and_bounds_impossible_targets() { + use std::cell::Cell; + let (p, seq, mut sources) = matte([0.0, 0.0, 0.0, 1.0], 64, 36, Some(-6.0)); + let reads = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + for source in sources.0.values_mut() { + *source = Arc::new(CrestSource(source.clone(), reads.clone())); + } + let range = TimeRange::new(Tick::ZERO, Tick(TICKS_PER_SECOND)); + let mut settings = ExportSettings::default(); + settings.effects.loudness = LoudnessNormalization { enabled: true, target_lufs: -14.0, true_peak_dbtp: -2.0 }; + for target in [-14.0, 2.0] { + settings.effects.loudness.target_lufs = target; + let mut audio = audio_out::AudioOut::new(p.clone(), seq, &settings, range).unwrap(); + let chunks = Cell::new(0); + audio + .measure(&settings, &sources, &|| { + chunks.set(chunks.get() + 1); + false + }) + .unwrap(); + let report = audio.loudness.unwrap(); + let result = audio.rest(&sources).unwrap(); + let (integrated, peak) = lufs(&result, 48_000); + eprintln!("target {target}: output {integrated:.3} LUFS, {peak:.3} dBTP, gain {:.3}, chunks {}", report.gain_db, chunks.get()); + assert!(chunks.get() <= 5, "one raw pass plus at most four limited trials"); + assert_eq!(result[0].len(), 48_000, "measuring must rewind output and limiter latency"); + assert!(peak <= -1.8, "limiting must remain enabled: {peak}"); + if target == -14.0 { + assert!((integrated - target).abs() < 0.15, "achieved {integrated}"); + assert!(report.gain_db > target - report.measured_lufs + 0.3, "fixture must require limiter correction"); + } else { + assert!(integrated < target - 1.0, "impossible target sacrifices loudness, not peak ceiling"); + } + } + // Cancel after raw measurement and one limited trial, before further refinement. + // Record source reads at cancellation to prove no further mixing occurs. + let mut audio = audio_out::AudioOut::new(p, seq, &settings, range).unwrap(); + let chunks = Cell::new(0); + let reads_at_cancel = Cell::new(None); + let cancel = || { + chunks.set(chunks.get() + 1); + if chunks.get() > 2 { + reads_at_cancel.set(Some(reads.load(Ordering::Relaxed))); + true + } else { + false + } + }; + assert!(matches!(audio.measure(&settings, &sources, &cancel), Err(ExportError::Cancelled))); + assert_eq!(chunks.get(), 3); + assert_eq!(reads_at_cancel.get(), Some(reads.load(Ordering::Relaxed))); +} From 8859e98fb7ba7d1a56842ad3c5e0abae3a33e579 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:09:25 +0700 Subject: [PATCH 187/260] export: estimate RGB TIFF sequence size from written channels --- crates/export/src/settings.rs | 3 ++- crates/export/src/settings_tests.rs | 38 +++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/crates/export/src/settings.rs b/crates/export/src/settings.rs index be8271825..1a427302d 100644 --- a/crates/export/src/settings.rs +++ b/crates/export/src/settings.rs @@ -486,7 +486,8 @@ impl ExportSettings { } 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::BmpSequence => px * 3.0 * 8.0 * fps, + Format::TiffSequence => px * if self.keeps_alpha() { 4.0 } else { 3.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, diff --git a/crates/export/src/settings_tests.rs b/crates/export/src/settings_tests.rs index fb68289ec..e9b49495d 100644 --- a/crates/export/src/settings_tests.rs +++ b/crates/export/src/settings_tests.rs @@ -861,6 +861,44 @@ fn extreme_bitrates_do_not_overflow_encoder_setup() { } } +#[test] +fn tiff_size_estimate_tracks_alpha_and_real_output() { + let (project, sequence, sources) = matte([0.0, 0.0, 1.0, 0.5], 320, 180, None); + let rate = FrameRate::FPS_24; + let duration = rate.tick_of(1); + for alpha in [false, true] { + let dir = Scratch::new(&format!("tiff-size-{alpha}")); + let mut settings = ExportSettings { + format: Format::TiffSequence, + path: dir.path("frame.tif"), + alpha, + ..Default::default() + }; + settings.range = Some(TimeRange::new(Tick::ZERO, duration)); + settings.validate().unwrap(); + + let expected = 320_u64 * 180 * if alpha { 4 } else { 3 }; + let summary = settings.summary(320, 180, rate, 48_000, duration); + assert_eq!(summary.estimated_bytes, expected, "alpha={alpha}"); + assert_eq!(settings.estimate_bytes(320, 180, rate, 48_000, duration), expected); + let result = export(&project, sequence, &settings, &sources, &Progress::default()).unwrap(); + assert_eq!(result.frames, 1); + let paths = files_in(&dir.0); + assert_eq!(paths.len(), 1, "{paths:?}"); + let actual_bytes = std::fs::metadata(dir.0.join(&paths[0])).unwrap().len(); + assert!(actual_bytes.abs_diff(summary.estimated_bytes) < 1024, "alpha={alpha}: estimate={} actual={actual_bytes}", summary.estimated_bytes); + } + + // Other uncompressed formats already have the correct channel policy. + for format in [Format::BmpSequence, Format::TgaSequence] { + let rgb = ExportSettings { format, alpha: false, ..Default::default() }; + let rgba = ExportSettings { format, alpha: true, ..Default::default() }; + assert_eq!(rgb.estimate_bytes(320, 180, rate, 48_000, duration), 320 * 180 * 3); + let expected = if format == Format::BmpSequence { 3 } else { 4 }; + assert_eq!(rgba.estimate_bytes(320, 180, rate, 48_000, duration), 320 * 180 * expected); + } +} + #[test] fn gpu_rendering_is_off_by_default() { assert_eq!(ExportSettings::default().gpu_rendering, crate::GpuRendering::Off); From d79248489b90c494a3e11427b14d07dc05fa79eb Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 17:12:33 +1100 Subject: [PATCH 188/260] Refuse timeline.move when the clip's source track is locked. Moving off a locked track used to skip delete_items and overwrite a second item with the same clip id (#917). --- crates/edit/src/lib.rs | 5 ++++- crates/engine/src/timeline_move_tests.rs | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index bb4e6415b..3f059c253 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -503,7 +503,10 @@ pub fn move_items(seq: &mut Sequence, moves: &[(ClipId, TrackId, Tick)], insert_ 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 (src, it) = work.find_item(*c).ok_or(EditError::NoItem(*c))?; + if work.track(src).is_some_and(|t| t.locked) { + return Err(EditError::Locked); + } let mut it = it.clone(); if work.track(*dest).ok_or(EditError::NoTrack(*dest))?.locked { return Err(EditError::Locked); diff --git a/crates/engine/src/timeline_move_tests.rs b/crates/engine/src/timeline_move_tests.rs index 6f176b814..565edd55b 100644 --- a/crates/engine/src/timeline_move_tests.rs +++ b/crates/engine/src/timeline_move_tests.rs @@ -100,6 +100,23 @@ fn a_linked_partner_on_a_locked_track_stays_and_the_listed_clip_still_moves() { assert_eq!(*s.project, *before); } +/// Moving a clip off a locked track used to leave it there and place another item with the same +/// clip id on the destination (#917). The source is locked, so the move is refused. +#[test] +fn a_clip_on_a_locked_track_cannot_move_to_another_track() { + let mut s = demo(); + let (v_track, v, _, _) = pair_in_an_empty_sequence(&mut s, 0.0); + s.execute("timeline.setTrack", json!({"track": "V1", "locked": true})).unwrap(); + let before = s.project.clone(); + let e = s.execute("timeline.move", json!({"moves": [{"clip": v.id.0, "track": "V2", "time": v.start.0}], "linked": false})).unwrap_err(); + assert!(matches!(&e, EngineError::Edit(filmcraft_edit::EditError::Locked)), "{e:?}"); + assert_eq!(*s.project, *before); + let q = s.active_sequence().unwrap(); + assert_eq!(q.find_item(v.id).map(|(t, _)| t), Some(v_track)); + assert_eq!(q.video_tracks[1].items.len(), 0); + q.check().unwrap(); +} + /// A partner that would land where it already is does not move: re-placing it would drop the /// transitions at its edges and list it in `moved` for nothing. Moving only the picture to /// another track at the same time leaves the sound as it was. From 361ba9d58ce68c837010888a396ae0cbaa9b6cd3 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 17:13:54 +1100 Subject: [PATCH 189/260] #914: one undo step for a multi-point Trim by Frames. Selecting two clip edges and trimming by frames applied each edge as its own timeline.trim, so Undo restored only the last clip. Collapse those edits (and extend-to-playhead / apply-default-transition) into one history snapshot so Undo and Redo cover every selected point. --- crates/engine/src/trim.rs | 6 ++++++ crates/engine/src/trim_tests.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/crates/engine/src/trim.rs b/crates/engine/src/trim.rs index 951a873c8..7c004b2b8 100644 --- a/crates/engine/src/trim.rs +++ b/crates/engine/src/trim.rs @@ -171,7 +171,9 @@ pub fn toggle_type(s: &mut Session) -> Result { /// Trim every selected edit point by `delta` (edit point movement; positive = later). fn trim_all(s: &mut Session, delta: Tick) -> Result { + let n0 = s.history.undo.len(); let applied = trim_all_raw(s, &|_, _| Some(delta))?; + crate::clip_ops::collapse_history(s, n0, "Trim by Frames"); let first = applied.first().and_then(Value::as_i64).map(Tick).unwrap_or_default(); add_shift(s, first); Ok(json!({"applied": applied})) @@ -230,7 +232,9 @@ pub fn nudge(s: &mut Session, p: &Value) -> Result { /// `trim.extendToPlayhead`: move each selected edit point to the playhead. pub fn extend_to_playhead(s: &mut Session) -> Result { let ph = s.playhead(); + let n0 = s.history.undo.len(); let applied = trim_all_raw(s, &|seq, e| edit_time(seq, e).map(|t| ph - t))?; + crate::clip_ops::collapse_history(s, n0, "Extend Edit to Playhead"); let first = applied.first().and_then(Value::as_i64).map(Tick).unwrap_or_default(); add_shift(s, first); Ok(json!({"applied": applied})) @@ -343,6 +347,7 @@ pub fn monitor_info(s: &Session) -> Value { /// default video/audio transition at every selected edit point. pub fn apply_default_transitions(s: &mut Session) -> Result { let pts = s.state.edit_points.clone(); + let n0 = s.history.undo.len(); let mut ids = Vec::new(); for ep in &pts { let seq = s.active_sequence().ok_or(EngineError::NoSequence)?; @@ -356,6 +361,7 @@ pub fn apply_default_transitions(s: &mut Session) -> Result { if ids.is_empty() { return Err(EngineError::Other("no transition could be applied at the selected edit points".into())); } + crate::clip_ops::collapse_history(s, n0, "Apply Default Transitions"); Ok(json!({"transitions": ids})) } diff --git a/crates/engine/src/trim_tests.rs b/crates/engine/src/trim_tests.rs index b003e48dd..cb3d32ccd 100644 --- a/crates/engine/src/trim_tests.rs +++ b/crates/engine/src/trim_tests.rs @@ -237,6 +237,34 @@ fn extend_to_playhead_moves_each_unequal_out_point_to_the_playhead() { assert_eq!(q.find_item(b).unwrap().1.end(), ph); } +/// #914: Trim by Frames on two selected edit points is one undo (and redo) step. +#[test] +fn multipoint_trim_by_frames_is_one_undo_step() { + let mut s = demo(); + s.execute("sequence.linkedSelection", json!({"on": false})).unwrap(); + let c = v1(&s); + let (a, b) = (c[0], c[1]); + s.execute("trim.selectEditPoint", json!({"clip": a.0, "edge": "out", "kind": "trim"})).unwrap(); + s.execute("trim.selectEditPoint", json!({"clip": b.0, "edge": "out", "kind": "trim", "add": true})).unwrap(); + let undo0 = s.history.undo.len(); + let d = s.sequence_rate().tick_of(-1); + s.execute("trim.nudge", json!({"frames": -1})).unwrap(); + let q = s.active_sequence().unwrap(); + let a1 = q.find_item(ClipId(a.0)).unwrap().1.end(); + let b1 = q.find_item(ClipId(b.0)).unwrap().1.end(); + assert_eq!(a1, a.2 + d); + assert_eq!(b1, b.2 + d); + assert_eq!(s.history.undo.len(), undo0 + 1, "one undo step for both clips"); + s.execute("edit.undo", json!({})).unwrap(); + let q = s.active_sequence().unwrap(); + assert_eq!(q.find_item(ClipId(a.0)).unwrap().1.end(), a.2); + assert_eq!(q.find_item(ClipId(b.0)).unwrap().1.end(), b.2); + s.execute("edit.redo", json!({})).unwrap(); + let q = s.active_sequence().unwrap(); + assert_eq!(q.find_item(ClipId(a.0)).unwrap().1.end(), a1); + assert_eq!(q.find_item(ClipId(b.0)).unwrap().1.end(), b1); +} + /// #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. From df282bcdc12de8c2221175641b191635ff686d56 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 17:14:16 +1100 Subject: [PATCH 190/260] #918: keep fade-out on the clip end after Speed/Duration Non-ripple clip.speedDuration shortened the clip but left one-sided out-edge transitions at the old timeline range. Reposition them with the same edge-follow used for a regular trim. --- crates/edit/src/lib.rs | 1 + crates/edit/src/tests.rs | 24 ++++++++++++++++++++++++ crates/engine/src/clip_ops_tests.rs | 23 +++++++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index bb4e6415b..2f671f0e4 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -1061,6 +1061,7 @@ pub fn set_speed_group(seq: &mut Sequence, clips: &[ClipId], speed: f64, reverse it.speed = speed; it.reverse = reverse; it.duration = new_dur; + fades_follow_edges(work.track_mut(tid).ok_or(EditError::NoTrack(tid))?, *clip); origins.push((tid, old_end, delta)); } let main_from = origins.first().map(|o| o.1).unwrap_or_default(); diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 9272a94da..80e8846a1 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -492,6 +492,30 @@ fn fades_follow_a_regular_trim() { fx.seq.check().unwrap(); } +#[test] +fn fades_follow_a_non_ripple_speed_change() { + // #918: faster without ripple shortens the clip; the fade-out stays on the new out point. + let mut fx = Fx::new(); + let v1 = fx.v(0); + let a = fx.put(v1, 0, 100, 0); + fx.seq.track_mut(v1).unwrap().transitions = vec![Transition { + id: TransitionId(1), + effect: filmcraft_project::find_effect("cross_dissolve").unwrap().instance(), + start: f(90), + duration: f(10), + from: Some(a), + to: None, + align: Default::default(), + reverse: false, + }]; + let mut n = fx.next; + set_speed(&mut fx.seq, a, 2.0, false, false, &mut Fx::ctx(&mut n)).unwrap(); + let tr = &fx.seq.track(v1).unwrap().transitions[0]; + assert_eq!(fx.spans(v1), vec![(0, 50)]); + assert_eq!((tr.start, tr.duration), (f(40), f(10))); + 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 diff --git a/crates/engine/src/clip_ops_tests.rs b/crates/engine/src/clip_ops_tests.rs index 68eb470f1..6fbb93ddf 100644 --- a/crates/engine/src/clip_ops_tests.rs +++ b/crates/engine/src/clip_ops_tests.rs @@ -868,6 +868,29 @@ fn color_matte_color_changes_and_undoes() { assert!(s.execute("project.matteColor", json!({"item": matte, "color": "green"})).is_err()); } +/// #918: non-ripple Speed/Duration 200% halves a 100-frame clip; a 10-frame fade-out must +/// move from frames 90–100 to 40–50 (the new clip end), not stay past the clip. +#[test] +fn speed_duration_moves_fade_out_to_the_new_clip_end() { + let mut s = Session::default(); + s.execute("file.newSequence", json!({"name": "Speed fade", "video": 1, "audio": 0})).unwrap(); + let rate = s.sequence_rate(); + let seconds = rate.tick_of(100).seconds(); + let matte = s.execute("file.newColorMatte", json!({"color": "#ff0000", "seconds": seconds})).unwrap()["item"].as_u64().unwrap(); + let c = s.execute("timeline.place", json!({"item": matte, "track": "V1", "seconds": 0.0})).unwrap()["clips"][0].as_u64().unwrap(); + select(&mut s, &[c]); + s.execute("sequence.applyVideoTransition", json!({"edge": "out", "frames": 10})).unwrap(); + let before = s.active_sequence().unwrap().video_tracks[0].transitions[0].clone(); + assert_eq!((rate.frame_at(before.start), rate.frame_at(before.end())), (90, 100)); + s.execute("clip.speedDuration", json!({"speed": 200})).unwrap(); + let it = clip(&s, c); + let fade = &s.active_sequence().unwrap().video_tracks[0].transitions[0]; + assert_eq!((rate.frame_at(it.start), rate.frame_at(it.end())), (0, 50)); + assert_eq!((rate.frame_at(fade.start), rate.frame_at(fade.end())), (40, 50)); + assert_eq!(fade.from, Some(filmcraft_project::ClipId(c))); + assert_eq!(fade.to, None); +} + /// A damaged project whose subclip is its own parent (#66): `media_duration` followed the chain /// without a bound and overflowed the stack. It gives up after a few hops now. #[test] From 82c318ed5a45f9c595a6f26f35868e221d9ca53f Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 17:46:35 +1100 Subject: [PATCH 191/260] #915: Match Frame replacement maps source frames on reversed clips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace With Clip ▸ Match Frame used a forward-only offset, so a reversed clip lined the Source monitor up with the wrong media time (source 50 at sequence 20 became 69). Align source_in from reverse-aware source mapping. --- crates/engine/src/clip_ops.rs | 4 ++-- crates/engine/src/clip_ops_tests.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/clip_ops.rs b/crates/engine/src/clip_ops.rs index ec4804a3d..8a85b9eee 100644 --- a/crates/engine/src/clip_ops.rs +++ b/crates/engine/src/clip_ops.rs @@ -1715,8 +1715,8 @@ fn replace_with(s: &mut Session, p: &Value, how: Replace) -> Result { } let Some((_, it)) = q.find_item_mut(*c) else { continue }; let src_in = match how { - // the Source monitor frame lines up with the sequence playhead - Replace::MatchFrame => src_ph - Tick(((ph - it.start).0 as f64 * it.speed.abs()).round() as i64), + // the Source monitor frame lines up with the sequence playhead (reverse-aware) + Replace::MatchFrame => src_ph - (it.moving_source_time_at(ph) - it.source_in), _ if keep_in => { let kept = keep_within.map(|(first, last)| it.source_in.clamp(first, last)).unwrap_or(it.source_in); if kept != it.source_in { diff --git a/crates/engine/src/clip_ops_tests.rs b/crates/engine/src/clip_ops_tests.rs index 68eb470f1..57cb8bdcb 100644 --- a/crates/engine/src/clip_ops_tests.rs +++ b/crates/engine/src/clip_ops_tests.rs @@ -606,6 +606,32 @@ fn replace_with_clip_from_source_match_frame_and_bin() { assert!(s.execute("clip.replaceFromBin", json!({"item": music.0})).is_err()); } +/// Match Frame on a reversed clip maps the Source monitor frame through reverse playback, not as +/// if the clip still ran forward (source 50 at sequence 20 used to become source 69). +#[test] +fn replace_from_source_match_frame_on_a_reversed_clip() { + let mut s = demo(); + let rate = s.sequence_rate(); + s.execute("sequence.linkedSelection", json!({"on": false})).unwrap(); + let forest = item_named(&s, "Misty_Forest.mp4"); + s.execute("file.newSequence", json!({"name": "MatchFrameReverse"})).unwrap(); + s.execute("timeline.place", json!({"item": forest.0, "time": 0})).unwrap(); + let c = s.active_sequence().unwrap().video_tracks[0].items[0].clone(); + s.execute("timeline.trim", json!({"clip": c.id.0, "edge": "out", "mode": "regular", "delta": (rate.tick_of(60) - c.duration).0})).unwrap(); + select(&mut s, &[c.id.0]); + s.execute("clip.speedDuration", json!({"speed": 100, "reverse": true, "ripple": false})).unwrap(); + let got = clip(&s, c.id.0); + assert_eq!(rate.frame_at(got.duration), 60); + assert!(got.reverse); + s.execute("source.open", json!({"item": forest.0})).unwrap(); + s.execute("playhead.set", json!({"time": rate.tick_of(20).0})).unwrap(); + s.execute("source.setPlayhead", json!({"frame": 50})).unwrap(); + s.execute("clip.replaceFromSourceMatchFrame", json!({})).unwrap(); + let got = clip(&s, c.id.0); + assert_eq!(got.source_time_at(rate.tick_of(20)), rate.tick_of(50)); + assert_ne!(got.source_time_at(rate.tick_of(20)), rate.tick_of(69)); +} + /// Replace With Clip ▸ From Bin restarts the clip at the new item's In point (as Premiere does); /// `keepSourceIn` keeps the clip's place in the media instead. #[test] From 93be6e7da1b1af8feba8d0e0bab338035e56d07c Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 17:50:07 +1100 Subject: [PATCH 192/260] cli: exit 2 for invalid mcp --bridge address `mcp --bridge` treated a non-loopback address as a command failure (exit 1) while `exec` already mapped the same parse error through usage (exit 2). --- apps/filmcraft-cli/src/main.rs | 2 +- apps/filmcraft-cli/tests/cli.rs | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/filmcraft-cli/src/main.rs b/apps/filmcraft-cli/src/main.rs index 10592444f..3040d4341 100644 --- a/apps/filmcraft-cli/src/main.rs +++ b/apps/filmcraft-cli/src/main.rs @@ -468,7 +468,7 @@ async fn cli() { 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)), + Some(addr) => filmcraft_automation::FilmcraftMcp::bridge(addr).unwrap_or_else(|e| usage(e)), None => match Backend::open(&a) { Backend::Local(s) => filmcraft_automation::FilmcraftMcp::headless(*s), Backend::Bridge(_) => usage("mcp: use --bridge ADDR to drive a running app"), diff --git a/apps/filmcraft-cli/tests/cli.rs b/apps/filmcraft-cli/tests/cli.rs index 8552cc640..5548d6c6d 100644 --- a/apps/filmcraft-cli/tests/cli.rs +++ b/apps/filmcraft-cli/tests/cli.rs @@ -22,6 +22,9 @@ fn help_and_usage_errors() { assert_eq!(cli(&[]).status.code(), Some(2)); assert_eq!(cli(&["frobnicate"]).status.code(), Some(2)); assert_eq!(cli(&["exec", "file.newBin", "notkv"]).status.code(), Some(2)); + // Invalid --bridge is a usage error (exit 2) for both exec and mcp (#921). + assert_eq!(cli(&["--bridge", "0.0.0.0:9876", "exec", "inspect.project"]).status.code(), Some(2)); + assert_eq!(cli(&["mcp", "--bridge", "0.0.0.0:9876"]).status.code(), Some(2)); } #[test] From b388c61684d338e5520a077e9900950ae46511b7 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 17:52:32 +1100 Subject: [PATCH 193/260] Keep New Sequence open when the engine rejects the frame size. OK used to close the dialog even after file.newSequence failed, so reopening reset the name and settings. Stay open on validation errors so one field can be fixed. --- .../ui-egui/src/panels/sequence_settings.rs | 9 ++++++-- crates/ui-egui/tests/sequence_ui.rs | 23 +++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/sequence_settings.rs b/crates/ui-egui/src/panels/sequence_settings.rs index d7fe5d025..fb861e037 100644 --- a/crates/ui-egui/src/panels/sequence_settings.rs +++ b/crates/ui-egui/src/panels/sequence_settings.rs @@ -540,9 +540,12 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { .and_then(|_| if rest.is_empty() { Ok(Value::Null) } else { app.session.execute("sequence.settings", Value::Object(rest)) }); if let Err(e) = r { app.ui.status = e.to_string(); + keep = true; + } else { + keep = false; } app.ui.sequence_settings = d; - return false; + return keep; } if apply { let p = changes(&d, &cur, &cur_name); @@ -551,8 +554,10 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { && let Err(e) = app.session.execute("sequence.settings", Value::Object(p)) { app.ui.status = e.to_string(); + keep = true; + } else { + keep = false; } - keep = false; } app.ui.sequence_settings = d; keep diff --git a/crates/ui-egui/tests/sequence_ui.rs b/crates/ui-egui/tests/sequence_ui.rs index 460b7929a..3230520a9 100644 --- a/crates/ui-egui/tests/sequence_ui.rs +++ b/crates/ui-egui/tests/sequence_ui.rs @@ -673,3 +673,26 @@ fn new_sequence_opens_its_dialog() { assert!(d.ids("sequenceSettings.tab.color").iter().any(|i| i == "sequenceSettings.tab.color")); d.click("sequenceSettings.cancel"); } + +/// New Sequence stays open when the engine rejects the frame size so the rest of the draft is not lost. +#[test] +fn new_sequence_stays_open_on_invalid_frame_size() { + let mut d = Driver::demo(); + let n0 = d.app().session.project.sequences().count(); + d.ok("ui.menu.invoke", json!({"id": "file.newSequence"})); + d.frames(10); + { + let s = &mut d.app().ui.sequence_settings; + s.name = "Keep Me".into(); + // within the dialog's per-side range, over the engine's pixel budget + (s.width, s.height) = (20_000, 20_000); + } + d.frames(2); + d.click("sequenceSettings.ok"); + d.frames(2); + assert_eq!(d.app().session.project.sequences().count(), n0, "nothing is made"); + assert!(!d.ids("sequenceSettings.").is_empty(), "dialog stays open: {}", d.app().ui.status); + assert_eq!(d.label("sequenceSettings.name"), "Keep Me"); + assert_eq!(d.label("sequenceSettings.width"), "20000"); + assert_eq!(d.label("sequenceSettings.height"), "20000"); +} From c2c7f1206c79dc554eea2ff6871380414a224c74 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 08:53:27 +0200 Subject: [PATCH 194/260] #790: Matroska audio oracle checks the encoded signal, robust to older ffmpeg ffmpeg's CLI handles Matroska priming differently by version: 9.x trims CodecDelay, 2023-24 static builds keep the priming samples (AAC 1024, AC-3 / E-AC-3 256, MP3 1105), so a +-40-sample best-lag search against its decode landed on a spurious minimum (4 or 24 samples). matroska_audio_oracle now has ffmpeg encode a reference signal we generate (low-passed xorshift noise) and asserts our decode lines up with that signal to the sample, the stream's own CodecDelay trimmed: version-independent, and it fails on the old ms-rounded trim (-16). ffmpeg's decode is compared after a best-lag search bounded by the CodecDelay; its lag is reported, the waveform must match (< 1e-3). E-AC-3 joins the cases. eac3_oracle's .mkv case accepts lag 0 or the 256-sample priming; MP4 / MOV / TS stay strictly at lag 0. Verified with ffmpeg 9.0.2 and the static N-71064 (2024-06) build. Co-Authored-By: Claude Opus 5.5 --- crates/codecs/tests/eac3_oracle.rs | 18 +++-- crates/codecs/tests/matroska_audio_oracle.rs | 84 ++++++++++++++++---- docs/file-format-parity.md | 2 +- docs/gaps.md | 4 +- 4 files changed, 82 insertions(+), 26 deletions(-) diff --git a/crates/codecs/tests/eac3_oracle.rs b/crates/codecs/tests/eac3_oracle.rs index ee7f335d3..7d875d49c 100644 --- a/crates/codecs/tests/eac3_oracle.rs +++ b/crates/codecs/tests/eac3_oracle.rs @@ -53,12 +53,18 @@ fn eac3_in_mp4_matroska_and_transport_streams() { }; 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"); + // Matroska stores the encoder's 256-sample priming as CodecDelay, which we trim exactly + // (checked to the sample against the encoded signal in `matroska_audio_oracle`). Whether + // ffmpeg's CLI trims it too depends on its version (9.x does; 2023-24 builds keep the + // priming samples), so for .mkv its decode may start 256 samples earlier than ours. + // Elsewhere the samples line up exactly, whatever the version. + let lags: Vec = match name.ends_with(".mkv") { + true => (-32..=32).chain(-256 - 32..=-256 + 32).collect(), + false => (-32..=32).collect(), + }; + let (worst, lag) = lags.into_iter().map(|lag| (snr(&ours, &want, ch, lag), lag)).fold((f64::MIN, 0), |a, b| if b.0 > a.0 { b } else { a }); + let ok = if name.ends_with(".mkv") { lag == 0 || lag == -256 } else { lag == 0 }; + assert!(ok, "{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; diff --git a/crates/codecs/tests/matroska_audio_oracle.rs b/crates/codecs/tests/matroska_audio_oracle.rs index ff7822740..3ded7af07 100644 --- a/crates/codecs/tests/matroska_audio_oracle.rs +++ b/crates/codecs/tests/matroska_audio_oracle.rs @@ -49,45 +49,95 @@ fn matroska_audio_plays_without_holes() { } } -/// The lag in `-max..=max` at which `ours` best matches `theirs` (least mean |difference| over -/// `from..from + len`). -fn best_lag(ours: &[f32], theirs: &[f32], from: usize, len: usize, max: i64) -> i64 { - let err = |lag: i64| -> f64 { (from..from + len).map(|i| (ours[i] as f64 - theirs[(i as i64 + lag) as usize] as f64).abs()).sum::() / len as f64 }; - (-max..=max).min_by(|&a, &b| err(a).total_cmp(&err(b))).unwrap() +/// Mean |difference| between `ours[i]` and `theirs[i + lag]` over `from..from + len`. +fn mean_err(ours: &[f32], theirs: &[f32], from: usize, len: usize, lag: i64) -> f64 { + (from..from + len).map(|i| (ours[i] as f64 - theirs[(i as i64 + lag) as usize] as f64).abs()).sum::() / len as f64 +} + +/// The lag in `lags` at which `ours` best matches `theirs` (least [`mean_err`]), and that error. +fn best_lag(ours: &[f32], theirs: &[f32], from: usize, len: usize, lags: std::ops::RangeInclusive) -> (i64, f64) { + lags.map(|lag| (lag, mean_err(ours, theirs, from, len, lag))).min_by(|a, b| a.1.total_cmp(&b.1)).unwrap() +} + +/// A reference signal we control: low-passed white noise from a fixed xorshift seed. Its +/// autocorrelation dies out within a few samples, so even a one-sample misalignment shows. +fn noise(n: usize) -> Vec { + let (mut state, mut y) = (0x9E37_79B9_7F4A_7C15u64, 0f32); + (0..n) + .map(|_| { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + let white = (state >> 40) as f32 / (1u64 << 24) as f32 - 0.5; + y = 0.5 * y + 0.4 * white; + y + }) + .collect() } /// #790: ffmpeg stores the encoder priming as `CodecDelay` (1024 samples = 21.333 ms for AAC at /// 48 kHz, 256 = 5.805 ms for AC-3 at 44.1 kHz, 1105 = 25.057 ms for MP3), and block timestamps to /// the millisecond. Taking the rounded delay as the start of the first packet played the whole -/// track up to 16 samples late; the decode must line up with ffmpeg's to the sample. +/// track up to 16 samples late. +/// +/// The strict check is version-independent: ffmpeg encodes a signal we generate, and our decode +/// must line up with that signal to the sample (the stream's `CodecDelay` trimmed exactly). ffmpeg's +/// own decode is compared only after a bounded best-lag search: how its CLI handles Matroska +/// priming depends on the version (9.x trims `CodecDelay`, 2023-24 builds keep the priming +/// samples), so its lag is reported, not asserted. #[test] fn matroska_audio_is_sample_aligned_with_ffmpeg() { let ff = filmcraft_testkit::require_ffmpeg!(); - let cases: [(&str, u32, &[&str]); 5] = [ - ("noise_aac_48k.mkv", 48_000, &["-c:a", "aac", "-b:a", "256k"]), - ("noise_aac_44k.mkv", 44_100, &["-c:a", "aac", "-b:a", "256k"]), - ("noise_ac3_44k.mkv", 44_100, &["-c:a", "ac3", "-b:a", "448k"]), - ("noise_ac3_48k.mkv", 48_000, &["-c:a", "ac3", "-b:a", "448k"]), - ("noise_mp3_44k.mkv", 44_100, &["-c:a", "libmp3lame", "-b:a", "320k"]), + let cases: [(&str, u32, &[&str]); 6] = [ + ("ref_aac_48k.mkv", 48_000, &["-c:a", "aac", "-b:a", "256k"]), + ("ref_aac_44k.mkv", 44_100, &["-c:a", "aac", "-b:a", "256k"]), + ("ref_ac3_44k.mkv", 44_100, &["-c:a", "ac3", "-b:a", "448k"]), + ("ref_ac3_48k.mkv", 48_000, &["-c:a", "ac3", "-b:a", "448k"]), + ("ref_eac3_48k.mkv", 48_000, &["-c:a", "eac3", "-b:a", "384k"]), + ("ref_mp3_44k.mkv", 44_100, &["-c:a", "libmp3lame", "-b:a", "320k"]), ]; for (name, rate, codec) in cases { - let input = format!("anoisesrc=color=pink:seed=7:amplitude=0.5:sample_rate={rate}:duration=2"); - let args: Vec<&str> = ["-f", "lavfi", "-i", input.as_str(), "-ac", "1"].iter().copied().chain(codec.iter().copied()).collect(); + let reference = noise(rate as usize * 2); + let raw = format!("ref_noise_{rate}.f32"); + let raw_path = filmcraft_testkit::fixtures::generate(&dir().join(&raw), |tmp| { + std::fs::write(tmp, reference.iter().flat_map(|v| v.to_le_bytes()).collect::>()).is_ok() + }) + .unwrap(); + let (raw_arg, rate_arg) = (raw_path.to_str().unwrap().to_string(), rate.to_string()); + let args: Vec<&str> = ["-f", "f32le", "-ar", &rate_arg, "-ac", "1", "-i", &raw_arg].iter().copied().chain(codec.iter().copied()).collect(); let Some(path) = fixture(&ff, name, &args) else { eprintln!("SKIPPED ({name}): this ffmpeg can't encode it"); continue; }; - let src = filmcraft_codecs::open_bytes(name, bytes(&path)).unwrap(); + let file = bytes(&path); + let mkv = filmcraft_matroska::open(file.clone()).unwrap(); + let delay_ns = mkv.tracks.first().map_or(0, |t| t.codec_delay_ns) as i64; + let delay = (delay_ns * rate as i64 + 500_000_000) / 1_000_000_000; + assert!(delay > 0, "{name}: no CodecDelay"); + let src = filmcraft_codecs::open_bytes(name, file).unwrap(); let frames = rate as usize * 3 / 2; let mut ours = Vec::with_capacity(frames); while ours.len() < frames { let n = 1000.min(frames - ours.len()); ours.extend_from_slice(&src.audio(ours.len() as i64, n, rate).unwrap().channels[0]); } + let (from, len, bound) = (rate as usize / 4, rate as usize / 4, delay + 32); + // strict: the encoder's input, to the sample + let (lag, err) = best_lag(&ours, &reference, from, len, -bound..=bound); + let level = reference[from..from + len].iter().map(|v| v.abs() as f64).sum::() / len as f64; + assert_eq!(lag, 0, "{name}: our decode is {lag} samples off the encoded signal (CodecDelay {delay} samples)"); + assert!(err < 0.25 * level, "{name}: decode error {err:.2e} against a signal level of {level:.2e}"); + // ffmpeg's decode, wherever its version puts the priming let theirs = ffmpeg_audio_f32(&ff, &path, &[]); assert!(theirs.len() >= frames, "{name}: ffmpeg decoded {} samples", theirs.len()); - let lag = best_lag(&ours, &theirs, rate as usize / 4, rate as usize, 40); - assert_eq!(lag, 0, "{name}: our decode is {lag} samples off ffmpeg's"); + let (ff_lag, ff_err) = best_lag(&ours, &theirs, from, len, -bound..=bound); + let priming = match ff_lag { + 0 => "trims CodecDelay".to_string(), + l if l == delay => "keeps the priming samples".to_string(), + l => format!("is {l} samples off"), + }; + println!("{name}: CodecDelay {delay} samples; vs the encoded signal lag 0, error {err:.2e} (level {level:.2e}); ffmpeg {priming}, error {ff_err:.2e}"); + assert!(ff_err < 1e-3, "{name}: differs from ffmpeg's decode by {ff_err:.2e} at its best lag {ff_lag}"); // read from the middle, as a seek does let at = rate as i64 / 2; let mid = src.audio(at, 2000, rate).unwrap().channels[0].clone(); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 3071ec16e..45fc3d3cc 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -39,7 +39,7 @@ only). "Tested" names the evidence. | 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; audio sample-aligned with ffmpeg (#790) | 75% | +| 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; audio sample-aligned with the encoded signal (#790) | 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% | diff --git a/docs/gaps.md b/docs/gaps.md index d5e5482fa..e68c870a6 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -172,8 +172,8 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** - **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). -- **Have:** Matroska AAC / MP3 / AC-3 sample-aligned with ffmpeg's decode (the exact - `CodecDelay` is trimmed, not the 1 ms-rounded one; #790). +- **Have:** Matroska AAC / MP3 / AC-3 / E-AC-3 sample-aligned with the signal the encoder was + given (the exact `CodecDelay` is trimmed, not the 1 ms-rounded one; #790). ## G11. No plugin hosting From 82609afec97f67df614ca1f3946e3c08541e3426 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 17:56:18 +1100 Subject: [PATCH 195/260] Fix Automate to Sequence dissolve duration when overlap is clamped. Placement already limited overlap to half of each clip, but Cross Dissolve still used the requested overlapFrames, so a 30-frame dissolve on two 20-frame clips started before the first clip. Apply the clamped per-cut overlap as the transition length. --- crates/engine/src/project_tools.rs | 23 +++++++++++++-------- crates/engine/src/project_tools_tests.rs | 26 ++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/project_tools.rs b/crates/engine/src/project_tools.rs index a0e2d181a..459dd411c 100644 --- a/crates/engine/src/project_tools.rs +++ b/crates/engine/src/project_tools.rs @@ -1171,7 +1171,8 @@ fn automate_to_sequence(s: &mut Session, p: &Value) -> Result { let d = rate.tick_of(rate.frame_at(r.duration + Tick(fd.0 / 2)).max(1)); plan.push((*id, TimeRange::new(r.start, d))); } - // destinations + // destinations. `cut_overlap[k]` is the overlap between clip k and k+1 (placement-clamped). + let mut cut_overlap: Vec = Vec::new(); let starts: Vec = if placement == "unnumberedMarkers" { let mut m: Vec = q.markers.iter().filter(|m| m.name.is_empty() && m.kind == MarkerKind::Comment).map(|m| rate.snap(m.start)).collect(); m.sort(); @@ -1189,6 +1190,7 @@ fn automate_to_sequence(s: &mut Session, p: &Value) -> Result { if k > 0 { let prev = plan[k - 1].1.duration; let ov = overlap.min(Tick(prev.0 / 2)).min(Tick(r.duration.0 / 2)); + cut_overlap.push(ov); at -= rate.snap(ov); } v.push(at); @@ -1239,20 +1241,23 @@ fn automate_to_sequence(s: &mut Session, p: &Value) -> Result { let mut transitions = 0; if placement == "sequentially" && (vt || at) { let q = s.active_sequence().ok_or(EngineError::NoSequence)?; + let default_frames = s.project.settings.default_transition_duration_frames; let mut edges = Vec::new(); - for (_, ids) in placed.iter().skip(1) { + for ((_, ids), ov) in placed.iter().skip(1).zip(cut_overlap.iter().chain(std::iter::repeat(&overlap))) { + let frames = if *ov > Tick::ZERO { rate.frame_at(*ov) } else { default_frames }; for c in ids { let Some((tid, _)) = q.find_item(*c) else { continue }; let kind = q.track(tid).map(|t| t.kind); - if (kind == Some(TrackKind::Video) && vt) || (kind == Some(TrackKind::Audio) && at) { - edges.push((*c, kind == Some(TrackKind::Video))); - } + let cmd = match kind { + Some(TrackKind::Video) if vt => "sequence.applyVideoTransition", + Some(TrackKind::Audio) if at => "sequence.applyAudioTransition", + _ => continue, + }; + edges.push((cmd, *c, frames)); } } - let frames = if overlap > Tick::ZERO { rate.frame_at(overlap) } else { s.project.settings.default_transition_duration_frames }; - for (c, video) in edges { - let id = if video { "sequence.applyVideoTransition" } else { "sequence.applyAudioTransition" }; - if s.execute(id, json!({"clip": c.0, "edge": "in", "frames": frames})).is_ok() { + for (cmd, c, frames) in edges { + if s.execute(cmd, json!({"clip": c.0, "edge": "in", "frames": frames})).is_ok() { transitions += 1; } } diff --git a/crates/engine/src/project_tools_tests.rs b/crates/engine/src/project_tools_tests.rs index e00af7326..82291264f 100644 --- a/crates/engine/src/project_tools_tests.rs +++ b/crates/engine/src/project_tools_tests.rs @@ -445,6 +445,32 @@ fn automate_to_sequence_places_clips_with_overlap_and_transitions() { assert!(!s.is_enabled("clip.automateToSequence")); } +/// Issue #916: overlap is clamped to half of each clip for placement, but the Cross Dissolve +/// used the unclamped `overlapFrames` and started before the first clip. +#[test] +fn automate_to_sequence_clamps_transition_to_overlap() { + let mut s = demo(); + s.execute("file.newSequence", json!({"name": "Auto", "fps": 23.976})).unwrap(); + let rate = s.sequence_rate(); + let names = ["Misty_Forest.mp4", "Ocean_Sunset.mp4"]; + let ids: Vec = names.iter().map(|n| item_named(&s, n)).collect(); + for id in &ids { + s.execute("project.setMarks", json!({"item": id.0, "in": 0, "out": rate.tick_of(19).0})).unwrap(); + } + s.execute("project.select", json!({"items": ids.iter().map(|i| i.0).collect::>()})).unwrap(); + s.execute("clip.automateToSequence", json!({"ordering": "selection", "method": "overwrite", "overlapFrames": 30})).unwrap(); + let q = s.active_sequence().unwrap(); + let v = &q.video_tracks[0]; + assert_eq!(v.items.len(), 2); + assert_eq!(v.items[0].start, Tick::ZERO); + assert_eq!(v.items[1].start, rate.tick_of(10), "overlap clamped to half of a 20-frame clip"); + assert_eq!(v.transitions.len(), 1); + let t = &v.transitions[0]; + assert_eq!(t.duration, rate.tick_of(10), "dissolve matches clamped overlap, not 30"); + assert!(t.start >= v.items[0].start, "dissolve must not start before the first clip: {t:?}"); + assert!(t.end() <= v.items[1].end(), "dissolve must not end after the second clip: {t:?}"); +} + #[test] fn system_report_lists_the_build() { let s = &mut Session::default(); From 6dd829dd61b2c8c0d225571b42e9930f86a8eccc Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 09:14:08 +0200 Subject: [PATCH 196/260] #820: count only quick deaths, two in a row; notice with reset and --graphics Review on #827: a start marker left pending by any early exit (force-quit, hang, unrelated crash, two launches racing) moved the user down a rung for good, with no way back but deleting a file. - Each start writes its own attempt file (graphics-start/-.json) that is removed after two painted frames, on a normal exit, or 15 s after launch whatever happened. A later launch only counts attempts that are older than that window, so a hang, a later crash or force-quit, and an instance that is still starting are never failures. - graphics-start.json now holds {mode, failures}: two failed starts in a row in the same mode move on to the next one; a start that paints resets the count. A wgpu start-up error still moves on at once. - All files are written to a per-process temp name and renamed into place, so concurrent launches never see or leave half a file. - A fallback mode shows a notice (graphics.notice) with Use Default Graphics (graphics.notice.reset) and Close; es, fr, ja and zh-cn strings. - --graphics default|dx12|software picks a mode by hand and keeps it; `--graphics default` undoes a fallback. Modes this platform lacks are refused. Co-Authored-By: Claude Opus 5.5 --- apps/filmcraft/src/args.rs | 61 +- apps/filmcraft/src/graphics.rs | 692 +++++++++++++++++---- apps/filmcraft/src/main.rs | 17 +- crates/ui-egui/src/graphics_notice.rs | 83 +++ crates/ui-egui/src/i18n/es.tsv | 6 + crates/ui-egui/src/i18n/fr.tsv | 6 + crates/ui-egui/src/i18n/ja.tsv | 6 + crates/ui-egui/src/i18n/zh-cn.tsv | 6 + crates/ui-egui/src/lib.rs | 9 + crates/ui-egui/tests/graphics_notice_ui.rs | 123 ++++ docs/gaps.md | 15 +- 11 files changed, 885 insertions(+), 139 deletions(-) create mode 100644 crates/ui-egui/src/graphics_notice.rs create mode 100644 crates/ui-egui/tests/graphics_notice_ui.rs diff --git a/apps/filmcraft/src/args.rs b/apps/filmcraft/src/args.rs index 5e1aad2b2..946cd6604 100644 --- a/apps/filmcraft/src/args.rs +++ b/apps/filmcraft/src/args.rs @@ -15,6 +15,9 @@ Options: --no-recover start without asking about unsaved changes --data-dir folder for auto-save, crash recovery and logs (also: FILMCRAFT_DATA_DIR) + --graphics start with this graphics mode and keep it: default, + dx12 (Windows), software (Windows, Linux); + `--graphics default` undoes an automatic fallback --version print the version and exit -h, --help print this help and exit "; @@ -39,6 +42,8 @@ pub struct Launch { pub startup_flag: bool, pub recover: Option, pub data_dir: Option, + /// `--graphics `: start with this graphics mode and keep it (see `graphics.rs`). + pub graphics: Option, } /// Parse the arguments after the program name. `env_control_port` is the value of @@ -49,7 +54,16 @@ pub struct Launch { /// a script waiting for it. That holds for the whole line, so `--version --bogus` is an error /// too, and for a port from the environment exactly as for one from `--control`. pub fn parse(env_control_port: Option, args: impl IntoIterator) -> Result { - let mut l = Launch { control_port: None, control_input_only: false, files: Vec::new(), demo: true, startup_flag: false, recover: None, data_dir: None }; + let mut l = Launch { + control_port: None, + control_input_only: false, + files: Vec::new(), + demo: true, + startup_flag: false, + recover: None, + data_dir: None, + graphics: None, + }; let (mut help, mut version) = (false, false); let mut args = args.into_iter(); while let Some(a) = args.next() { @@ -66,6 +80,10 @@ pub fn parse(env_control_port: Option, args: impl IntoIterator l.recover = Some(true), "--no-recover" => l.recover = Some(false), "--data-dir" => l.data_dir = Some(args.next().map(std::path::PathBuf::from).ok_or("--data-dir needs a folder")?), + "--graphics" => { + let name = args.next().ok_or("--graphics needs a mode: default, dx12 or software")?; + l.graphics = Some(graphics_mode(&name)?); + } // everything after `--` is a file, whatever it looks like "--" => l.files.extend(args.by_ref()), // macOS adds a process serial number when it launches an app bundle @@ -91,6 +109,17 @@ pub fn parse(env_control_port: Option, args: impl IntoIterator Result { + use crate::graphics::{LADDER, Mode}; + let here = || LADDER.iter().map(|m| m.name()).collect::>().join(", "); + let mode = Mode::parse(name).ok_or_else(|| format!("--graphics needs one of {}, got `{name}`", here()))?; + if !LADDER.contains(&mode) { + return Err(format!("--graphics {name} is not available on this platform (use one of {})", here())); + } + Ok(mode) +} + #[cfg(test)] mod tests { use super::*; @@ -166,7 +195,16 @@ mod tests { let none = launch(&[]); assert_eq!( none, - Launch { control_port: None, control_input_only: false, files: vec![], demo: true, startup_flag: false, recover: None, data_dir: None } + Launch { + control_port: None, + control_input_only: false, + files: vec![], + demo: true, + startup_flag: false, + recover: None, + data_dir: None, + graphics: None + } ); let l = launch(&["--control", "9876", "--empty", "--no-recover", "--data-dir", "/tmp/fc", "cut.fcproj", "a.mp4"]); assert_eq!(l.control_port, Some(9876)); @@ -188,4 +226,23 @@ mod tests { assert_eq!(parse_args(&["--control-input-only", "--help"]), Ok(Cli::Help)); assert!(USAGE.contains("--control-input-only")); } + + #[test] + fn graphics_picks_one_of_this_platforms_modes() { + use crate::graphics::{LADDER, Mode}; + assert_eq!(launch(&[]).graphics, None); + assert_eq!(launch(&["--graphics", "default"]).graphics, Some(Mode::Default)); + for mode in LADDER { + assert_eq!(launch(&["--graphics", mode.name()]).graphics, Some(*mode)); + } + // a mode this platform has not is refused, not ignored + for mode in [Mode::Dx12, Mode::Software].into_iter().filter(|m| !LADDER.contains(m)) { + assert!(parse_args(&["--graphics", mode.name()]).unwrap_err().contains("not available")); + } + assert!(parse_args(&["--graphics"]).is_err(), "missing mode"); + assert!(parse_args(&["--graphics", "vulkan"]).unwrap_err().contains("vulkan")); + assert!(parse_args(&["--graphics", "DEFAULT"]).is_err()); + assert!(parse_args(&["--graphics", &"x".repeat(1 << 16)]).is_err()); + assert!(USAGE.contains("--graphics")); + } } diff --git a/apps/filmcraft/src/graphics.rs b/apps/filmcraft/src/graphics.rs index 511ec982c..e79ace08e 100644 --- a/apps/filmcraft/src/graphics.rs +++ b/apps/filmcraft/src/graphics.rs @@ -4,15 +4,36 @@ //! A graphics driver can crash inside wgpu instance or device creation (Intel's Vulkan driver //! `igvk64.dll`, #512 #413; AMD's OpenGL driver `atio6axx.dll`, #477), where no Rust error //! handling runs, so every launch died the same way until the user set `WGPU_BACKEND` by hand. -//! Each start now leaves a marker in the data folder ([`STATE_FILE`]) that is cleared once the -//! window has painted. A marker still pending at the next launch means that start died: the launch -//! moves on to the next [`Mode`] of the platform's [`LADDER`] and keeps it. A start-up error that -//! wgpu does report (no adapter, device creation failed) moves on within the same process. An -//! explicit `WGPU_BACKEND` leaves the marker alone: the user is in charge. +//! +//! # When a start counts as failed +//! The data folder keeps the mode to start with and the failed starts in a row in it +//! ([`STATE_FILE`]). Each start also leaves its own attempt file (`graphics-start/-.json`, +//! so concurrent launches never write the same file) that is removed once the window has painted +//! two frames, when the process ends normally, or [`DEATH_WINDOW`] after launch, whatever happened +//! by then. An attempt file found by a later launch therefore means a process that died within +//! [`DEATH_WINDOW`] of its start without painting: a driver crash, typically. A start that hangs, +//! or is force-quit or crashes later, is not counted; neither is another instance that is still +//! starting (its attempt is younger than [`DEATH_WINDOW`] and is left alone). After +//! [`FAILURES_TO_MOVE_ON`] such failures in a row in the same mode, the launch moves on to the next +//! [`Mode`] of the platform's [`LADDER`] and keeps it; a start that paints resets the count. A +//! start-up error that wgpu does report (no adapter, device creation failed) moves on at once, +//! within the same process. +//! +//! # Getting back +//! A fallback mode shows a notice with a reset button in the window (`graphics.notice.reset`, +//! [`GraphicsStart::reset_to_default`]); `--graphics default` does the same from the command +//! line, and `--graphics dx12|software` picks a mode by hand. An explicit `WGPU_BACKEND` leaves +//! the saved state alone: the user is in charge. +//! +//! All files are written beside and renamed into place, so a reader never sees half a file; two +//! instances updating [`STATE_FILE`] at the same moment can lose one update (a failure counted +//! once less, a confirmation repeated by the next start), never corrupt it. use std::io::Read as _; use std::path::{Path, PathBuf}; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use eframe::{ NativeOptions, @@ -20,9 +41,19 @@ use eframe::{ wgpu::{self, Backends}, }; -/// The start marker in the data folder: `{"mode": "", "pending": }`. Delete it to -/// start with the default graphics mode again. +/// The saved graphics state in the data folder: `{"mode": "", "failures": }`. pub const STATE_FILE: &str = "graphics-start.json"; +/// The folder in the data folder with one attempt file per start that has not painted yet. +pub const ATTEMPTS_DIR: &str = "graphics-start"; +/// How long after launch a start that has not painted may die and still count as failed. A +/// driver crash in instance or device creation happens within a second or two. +pub const DEATH_WINDOW: Duration = Duration::from_secs(15); +/// Failed starts in a row, in the same mode, that move on to the next mode. +pub const FAILURES_TO_MOVE_ON: u32 = 2; +/// Attempt files looked at per launch; anything beyond is left for a later launch. +const MAX_ATTEMPTS: usize = 64; +/// The state and attempt files are a few bytes; anything longer is not ours. +const MAX_FILE: u64 = 4096; /// How the window's graphics device is created. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -38,6 +69,7 @@ pub enum Mode { } impl Mode { + /// The name in [`STATE_FILE`] and on the command line (`--graphics `). pub fn name(self) -> &'static str { match self { Mode::Default => "default", @@ -46,7 +78,16 @@ impl Mode { } } - fn parse(name: &str) -> Option { + /// The English name shown in the interface (translated there). + pub fn label(self) -> &'static str { + match self { + Mode::Default => "default graphics", + Mode::Dx12 => "DirectX 12 only", + Mode::Software => "software rendering", + } + } + + pub fn parse(name: &str) -> Option { [Mode::Default, Mode::Dx12, Mode::Software].into_iter().find(|m| m.name() == name) } } @@ -66,107 +107,316 @@ fn next_in(ladder: &[Mode], mode: Mode) -> Option { ladder.get(i.checked_add(1)?).copied() } -/// This launch's mode from the saved marker (`(mode, pending)`; `None` when there is none or it -/// is unreadable), and whether it moved on because the last start died. -fn launch_mode(saved: Option<(Mode, bool)>, ladder: &[Mode]) -> (Mode, bool) { +/// [`STATE_FILE`]: the mode to start with and the failed starts in a row in it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Saved { + mode: Mode, + failures: u32, +} + +/// An attempt file left by a start: its process id (from the file name), mode and launch time. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Attempt { + pid: u32, + mode: Mode, + /// Seconds since the Unix epoch. + started: u64, +} + +/// What a launch makes of the saved state and the attempt files it found. +#[derive(Debug, PartialEq, Eq)] +struct Decision { + /// The state to start with and save. + saved: Saved, + /// Moved on to the next mode because the last starts failed. + moved_on: bool, + /// Per attempt: done with (counted, or not ours to count), delete it. + delete: Vec, +} + +/// This launch's mode from the saved state and the attempts other starts left. `now` is in Unix +/// seconds, `pid` is this process (an attempt with our process id is a dead process whose id was +/// reused), `window` is [`DEATH_WINDOW`] in seconds. +fn decide(saved: Option, attempts: &[Attempt], now: u64, pid: u32, window: u64, ladder: &[Mode]) -> Decision { let first = ladder.first().copied().unwrap_or(Mode::Default); - match saved { - // A mode this platform does not use (a data folder copied from another OS): start over. - Some((mode, _)) if !ladder.contains(&mode) => (first, false), - Some((mode, true)) => match next_in(ladder, mode) { - Some(next) => (next, true), - // Nothing left to try: the last mode is still the best chance. - None => (mode, false), - }, - Some((mode, false)) => (mode, false), - None => (first, false), + // Nothing saved, or a mode this platform does not use (a data folder copied from another OS). + let saved = saved.filter(|s| ladder.contains(&s.mode)).unwrap_or(Saved { mode: first, failures: 0 }); + let mut failures = saved.failures; + let mut delete = Vec::with_capacity(attempts.len()); + for a in attempts { + if a.started > now.saturating_add(window) { + // From the future (a clock that jumped back): unknown, and not worth keeping. + delete.push(true); + continue; + } + let dead = a.pid == pid || now.saturating_sub(a.started) >= window; + // A younger attempt is another instance starting right now: leave it to that instance. + delete.push(dead); + // Only failures of the mode we would start in count; older ones are history. + if dead && a.mode == saved.mode { + failures = failures.saturating_add(1); + } } + if failures >= FAILURES_TO_MOVE_ON { + return match next_in(ladder, saved.mode) { + Some(next) => Decision { saved: Saved { mode: next, failures: 0 }, moved_on: true, delete }, + // Nothing left to try: the last mode is still the best chance. + None => Decision { saved: Saved { mode: saved.mode, failures: 0 }, moved_on: false, delete }, + }; + } + Decision { saved: Saved { mode: saved.mode, failures }, moved_on: false, delete } } -/// `(mode, pending)` from the marker's text; `None` for anything else. -fn parse_state(text: &str) -> Option<(Mode, bool)> { - let v: serde_json::Value = serde_json::from_str(text).ok()?; - Some((Mode::parse(v.get("mode")?.as_str()?)?, v.get("pending")?.as_bool()?)) -} - -fn read_state(path: &Path) -> Option<(Mode, bool)> { - // The marker is a few bytes; anything longer is not ours. +fn read_small(path: &Path) -> Option { let mut text = String::new(); - std::fs::File::open(path).ok()?.take(4096).read_to_string(&mut text).ok()?; - parse_state(&text) + std::fs::File::open(path).ok()?.take(MAX_FILE).read_to_string(&mut text).ok()?; + Some(text) } -fn write_state(path: &Path, mode: Mode, pending: bool) -> std::io::Result<()> { +/// [`Saved`] from its text; `None` for anything else. A missing failure count is none. +fn parse_state(text: &str) -> Option { + let v: serde_json::Value = serde_json::from_str(text).ok()?; + let mode = Mode::parse(v.get("mode")?.as_str()?)?; + let failures = match v.get("failures") { + None => 0, + Some(n) => u32::try_from(n.as_u64()?).ok()?, + }; + Some(Saved { mode, failures }) +} + +fn state_json(s: Saved) -> String { + serde_json::json!({ "mode": s.mode.name(), "failures": s.failures }).to_string() +} + +/// An attempt from its file name (`-.json`) and text; `None` for anything else. +fn parse_attempt(file_name: &str, text: &str) -> Option { + let pid = file_name.strip_suffix(".json")?.split_once('-')?.0.parse().ok()?; + let v: serde_json::Value = serde_json::from_str(text).ok()?; + Some(Attempt { pid, mode: Mode::parse(v.get("mode")?.as_str()?)?, started: v.get("started")?.as_u64()? }) +} + +fn attempt_json(mode: Mode, started: u64) -> String { + serde_json::json!({ "mode": mode.name(), "started": started }).to_string() +} + +/// Write `text` beside `path` (a name only this process uses) and rename it into place, so a +/// start that dies mid-write, or another instance reading, never sees half a file. +fn write_atomic(path: &Path, text: &str) -> std::io::Result<()> { if let Some(dir) = path.parent() { std::fs::create_dir_all(dir)?; } - let text = serde_json::json!({ "mode": mode.name(), "pending": pending }).to_string(); - // Written beside and renamed, so a start that dies mid-write leaves the old marker whole. - let tmp = path.with_extension("json.tmp"); + let mut tmp = path.as_os_str().to_owned(); + tmp.push(format!(".{}.tmp", std::process::id())); + let tmp = PathBuf::from(tmp); std::fs::write(&tmp, text)?; - std::fs::rename(&tmp, path) + std::fs::rename(&tmp, path).inspect_err(|_| { + let _ = std::fs::remove_file(&tmp); + }) } -/// This launch's graphics mode and its start marker. +fn remove_quietly(path: &Path) { + match std::fs::remove_file(path) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => log::warn!("can't remove {}: {e}", path.display()), + _ => {} + } +} + +/// The attempt files in `dir` (at most [`MAX_ATTEMPTS`]): path and contents, `None` when unreadable. +fn list_attempts(dir: &Path) -> Vec<(PathBuf, Option)> { + let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() }; + entries + .filter_map(Result::ok) + .filter_map(|e| { + let name = e.file_name().into_string().ok()?; + name.ends_with(".json").then(|| { + let path = e.path(); + let attempt = read_small(&path).and_then(|text| parse_attempt(&name, &text)); + (path, attempt) + }) + }) + .take(MAX_ATTEMPTS) + .collect() +} + +fn unix_now() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs()) +} + +/// Attempt numbers within this process, so each attempt file has its own name. +static NEXT_ATTEMPT: AtomicU32 = AtomicU32::new(0); + +/// This launch's graphics mode and its files in the data folder. #[derive(Clone, Debug)] pub struct GraphicsStart { - /// The marker; `None` without a data folder or with `WGPU_BACKEND` set. - path: Option, + /// [`STATE_FILE`]; `None` without a data folder or with `WGPU_BACKEND` set. + state: Option, + /// This start's attempt file (with `state`). + attempt: Option, mode: Mode, + /// [`Self::reset_to_default`] was used: a later [`Self::confirm`] must not save this mode over it. + reset: Arc, } impl GraphicsStart { - /// Pick this launch's mode from the marker in `data_dir` and mark the start pending. - /// `explicit_backend` (`WGPU_BACKEND` set) starts in [`Mode::Default`] and leaves the marker - /// alone. - pub fn begin(data_dir: Option<&Path>, explicit_backend: bool) -> Self { - let path = if explicit_backend { None } else { data_dir.map(|d| d.join(STATE_FILE)) }; - let saved = path.as_deref().and_then(read_state); - let (mode, moved_on) = launch_mode(saved, LADDER); - let file = path.as_deref().map(|p| p.display().to_string()).unwrap_or_default(); - if moved_on { - let failed = saved.map_or("default", |(m, _)| m.name()); - log::warn!( - "the last start did not get its window up with graphics mode {failed}; starting with {} (delete {file} to try the default again)", - mode.name() - ); - } else if mode != Mode::Default { - log::info!("graphics mode {} after an earlier failed start (delete {file} to try the default again)", mode.name()); + /// Pick this launch's mode from the data folder and record the attempt. `forced` + /// (`--graphics`) starts in that mode and saves it. `explicit_backend` (`WGPU_BACKEND` set) + /// leaves the data folder alone and starts in `forced`, else [`Mode::Default`]. + pub fn begin(data_dir: Option<&Path>, explicit_backend: bool, forced: Option) -> Self { + let start = Self::begin_at(data_dir, explicit_backend, forced, unix_now(), std::process::id()); + if let Some(path) = start.attempt.clone() { + expire_later(path); } - let start = Self { path, mode }; - start.save(true); start } + /// [`Self::begin`] at `now` (Unix seconds) as process `pid`, without the expiry timer. + fn begin_at(data_dir: Option<&Path>, explicit_backend: bool, forced: Option, now: u64, pid: u32) -> Self { + let first = LADDER.first().copied().unwrap_or(Mode::Default); + let forced = forced.map(|m| if LADDER.contains(&m) { m } else { first }); + let reset = Arc::new(AtomicBool::new(false)); + let Some(dir) = data_dir.filter(|_| !explicit_backend) else { + return Self { state: None, attempt: None, mode: forced.unwrap_or(Mode::Default), reset }; + }; + let state = dir.join(STATE_FILE); + let attempts_dir = dir.join(ATTEMPTS_DIR); + let saved = read_small(&state).and_then(|t| parse_state(&t)); + let found = list_attempts(&attempts_dir); + let known: Vec = found.iter().filter_map(|(_, a)| *a).collect(); + let decision = decide(saved, &known, now, pid, DEATH_WINDOW.as_secs(), LADDER); + let mut to_delete = decision.delete.iter(); + for (path, attempt) in &found { + // unreadable files in our folder are debris (a different version, a damaged disk) + if attempt.is_none() || to_delete.next().copied().unwrap_or(false) { + remove_quietly(path); + } + } + let file = state.display(); + let next = match forced { + Some(mode) => { + log::info!("graphics mode {} chosen with --graphics", mode.name()); + Saved { mode, failures: 0 } + } + None => { + if decision.moved_on { + let failed = saved.map_or(first, |s| s.mode).name(); + log::warn!( + "the last {FAILURES_TO_MOVE_ON} starts with graphics mode {failed} died before their window painted; starting with {} (`--graphics default` or the notice in the window goes back)", + decision.saved.mode.name() + ); + } else if decision.saved.failures > 0 { + log::warn!("the last start with graphics mode {} died before its window painted", decision.saved.mode.name()); + } else if decision.saved.mode != Mode::Default { + log::info!("graphics mode {} after earlier failed starts ({file}; `--graphics default` goes back)", decision.saved.mode.name()); + } + decision.saved + } + }; + if saved != Some(next) + && let Err(e) = write_atomic(&state, &state_json(next)) + { + log::warn!("can't write {file}: {e}"); + } + let start = Self { state: Some(state), attempt: None, mode: next.mode, reset }; + start.with_new_attempt(&attempts_dir, now, pid) + } + + /// Record a new attempt file for the current mode. + fn with_new_attempt(mut self, dir: &Path, now: u64, pid: u32) -> Self { + let n = NEXT_ATTEMPT.fetch_add(1, Ordering::Relaxed); + let path = dir.join(format!("{pid}-{n}.json")); + match write_atomic(&path, &attempt_json(self.mode, now)) { + Ok(()) => self.attempt = Some(path), + Err(e) => log::warn!("can't write {}: {e}", path.display()), + } + self + } + pub fn mode(&self) -> Mode { self.mode } + /// Running in a fallback mode, i.e. not the platform default. + pub fn degraded(&self) -> bool { + self.mode != Mode::Default + } + /// Move on to the next mode after a start-up error wgpu reported; `false` when none is left. + /// The error is certain, so the next mode is saved at once. pub fn step_down(&mut self) -> bool { let Some(next) = next_in(LADDER, self.mode) else { return false; }; log::warn!("graphics mode {} failed to start; trying {}", self.mode.name(), next.name()); self.mode = next; - self.save(true); + if let Some(state) = self.state.clone() { + self.save(&state, Saved { mode: next, failures: 0 }); + if let Some(old) = self.attempt.take() { + remove_quietly(&old); + if let Some(dir) = old.parent() { + let path = self.clone().with_new_attempt(dir, unix_now(), std::process::id()).attempt; + if let Some(path) = &path { + expire_later(path.clone()); + } + self.attempt = path; + } + } + } true } - /// The window is up: keep this mode for the next launches. + /// The window is up: keep this mode, and the failed starts in a row are over. pub fn confirm(&self) { - self.save(false); + self.finish(); + let Some(state) = &self.state else { return }; + if self.reset.load(Ordering::Relaxed) { + return; + } + let ok = Saved { mode: self.mode, failures: 0 }; + if read_small(state).and_then(|t| parse_state(&t)) != Some(ok) { + self.save(state, ok); + } } - fn save(&self, pending: bool) { - if let Some(path) = &self.path - && let Err(e) = write_state(path, self.mode, pending) - { + /// This start is over, whether it painted or not (the window closed, or every mode failed + /// with an error that was reported): its attempt no longer means a crash. + pub fn finish(&self) { + if let Some(path) = &self.attempt { + remove_quietly(path); + } + } + + /// Start with the default mode next time (the notice's reset button). This process keeps + /// its mode: the graphics device can't be swapped under a running window. + pub fn reset_to_default(&self) -> Result<(), String> { + self.reset.store(true, Ordering::Relaxed); + let Some(state) = &self.state else { return Ok(()) }; + let first = LADDER.first().copied().unwrap_or(Mode::Default); + write_atomic(state, &state_json(Saved { mode: first, failures: 0 })).map_err(|e| format!("can't write {}: {e}", state.display()))?; + log::info!("graphics mode reset to {} for the next start", first.name()); + Ok(()) + } + + fn save(&self, path: &Path, s: Saved) { + if let Err(e) = write_atomic(path, &state_json(s)) { log::warn!("can't write {}: {e}", path.display()); } } } +/// Remove the attempt file at `path` [`DEATH_WINDOW`] from now: a start still alive then is not +/// counted as a failed one, painted or not. +fn expire_later(path: PathBuf) { + let spawned = std::thread::Builder::new().name("graphics-start".into()).spawn(move || { + let _ = std::panic::catch_unwind(move || { + std::thread::sleep(DEATH_WINDOW); + remove_quietly(&path); + }); + }); + if let Err(e) = spawned { + log::warn!("can't start the graphics start timer: {e}"); + } +} + /// Configure `options` for `mode`, before eframe creates the wgpu instance. `backend_override` /// is normally `Backends::from_env()`, i.e. `WGPU_BACKEND`. pub fn apply(options: &mut NativeOptions, mode: Mode, backend_override: Option) { @@ -269,46 +519,103 @@ mod tests { assert_eq!(configured(Mode::Default, None, false), (eframe_default.instance_descriptor.backends, false)); } - #[test] - fn a_start_that_died_moves_on_to_the_next_mode_and_keeps_it() { - // first launch - assert_eq!(launch_mode(None, WINDOWS), (Mode::Default, false)); - // the default start died in the driver: DX12 next, then software - assert_eq!(launch_mode(Some((Mode::Default, true)), WINDOWS), (Mode::Dx12, true)); - assert_eq!(launch_mode(Some((Mode::Dx12, true)), WINDOWS), (Mode::Software, true)); - assert_eq!(launch_mode(Some((Mode::Default, true)), LINUX), (Mode::Software, true)); - // a confirmed mode is kept - assert_eq!(launch_mode(Some((Mode::Dx12, false)), WINDOWS), (Mode::Dx12, false)); - assert_eq!(launch_mode(Some((Mode::Default, false)), WINDOWS), (Mode::Default, false)); - // nothing left to try: stay - assert_eq!(launch_mode(Some((Mode::Software, true)), WINDOWS), (Mode::Software, false)); - assert_eq!(launch_mode(Some((Mode::Default, true)), MACOS), (Mode::Default, false)); - // a mode this platform has not: start over - assert_eq!(launch_mode(Some((Mode::Dx12, false)), LINUX), (Mode::Default, false)); - assert_eq!(launch_mode(Some((Mode::Software, true)), MACOS), (Mode::Default, false)); - assert_eq!(launch_mode(None, &[]), (Mode::Default, false)); + const W: u64 = 15; + const NOW: u64 = 1_000_000; + const ME: u32 = 7; + + fn saved(mode: Mode, failures: u32) -> Option { + Some(Saved { mode, failures }) + } + + fn dead(mode: Mode) -> Attempt { + Attempt { pid: 1, mode, started: NOW - 100 } + } + + fn mode_after(s: Option, attempts: &[Attempt], ladder: &[Mode]) -> (Mode, u32, bool) { + let d = decide(s, attempts, NOW, ME, W, ladder); + (d.saved.mode, d.saved.failures, d.moved_on) } #[test] - fn the_marker_round_trips_and_rejects_anything_else() { + fn two_failed_starts_in_a_row_move_on_to_the_next_mode_and_keep_it() { + // first launch + assert_eq!(mode_after(None, &[], WINDOWS), (Mode::Default, 0, false)); + // one start died: counted, but the same mode is tried again + assert_eq!(mode_after(None, &[dead(Mode::Default)], WINDOWS), (Mode::Default, 1, false)); + // the second in a row moves on: DX12, then software + assert_eq!(mode_after(saved(Mode::Default, 1), &[dead(Mode::Default)], WINDOWS), (Mode::Dx12, 0, true)); + assert_eq!(mode_after(saved(Mode::Dx12, 1), &[dead(Mode::Dx12)], WINDOWS), (Mode::Software, 0, true)); + assert_eq!(mode_after(saved(Mode::Default, 1), &[dead(Mode::Default)], LINUX), (Mode::Software, 0, true)); + // two dead instances found at once count as two + assert_eq!(mode_after(None, &[dead(Mode::Default), dead(Mode::Default)], WINDOWS), (Mode::Dx12, 0, true)); + // a saved mode is kept + assert_eq!(mode_after(saved(Mode::Dx12, 0), &[], WINDOWS), (Mode::Dx12, 0, false)); + assert_eq!(mode_after(saved(Mode::Default, 1), &[], WINDOWS), (Mode::Default, 1, false)); + // nothing left to try: stay + assert_eq!(mode_after(saved(Mode::Software, 1), &[dead(Mode::Software)], WINDOWS), (Mode::Software, 0, false)); + assert_eq!(mode_after(saved(Mode::Default, 5), &[dead(Mode::Default)], MACOS), (Mode::Default, 0, false)); + // a mode this platform has not: start over + assert_eq!(mode_after(saved(Mode::Dx12, 1), &[dead(Mode::Dx12)], LINUX), (Mode::Default, 0, false)); + assert_eq!(mode_after(saved(Mode::Software, 0), &[], MACOS), (Mode::Default, 0, false)); + assert_eq!(mode_after(None, &[], &[]), (Mode::Default, 0, false)); + // a hostile count doesn't overflow + assert_eq!(mode_after(saved(Mode::Default, u32::MAX), &[dead(Mode::Default)], WINDOWS), (Mode::Dx12, 0, true)); + } + + #[test] + fn only_starts_that_died_soon_after_launch_in_the_current_mode_count() { + let starting = Attempt { pid: 2, mode: Mode::Default, started: NOW - 3 }; + let just_now = Attempt { pid: 3, mode: Mode::Default, started: NOW }; + let future = Attempt { pid: 4, mode: Mode::Default, started: NOW + 3600 }; + let older_mode = dead(Mode::Dx12); + let mine = Attempt { pid: ME, mode: Mode::Default, started: NOW - 1 }; + let d = decide(saved(Mode::Default, 0), &[starting, just_now, future, older_mode, mine], NOW, ME, W, WINDOWS); + // another instance still starting is left alone; one from the future is dropped; a + // failure in another mode is history; our own process id can only be a dead process + assert_eq!(d.delete, [false, false, true, true, true]); + assert_eq!((d.saved, d.moved_on), (Saved { mode: Mode::Default, failures: 1 }, false)); + // the clock a little behind another instance's: still starting + let ahead = Attempt { pid: 5, mode: Mode::Default, started: NOW + 2 }; + assert_eq!(decide(None, &[ahead], NOW, ME, W, WINDOWS).delete, [false]); + } + + #[test] + fn the_files_round_trip_and_reject_anything_else() { for mode in WINDOWS { - for pending in [false, true] { - let text = serde_json::json!({ "mode": mode.name(), "pending": pending }).to_string(); - assert_eq!(parse_state(&text), Some((*mode, pending))); + for failures in [0, 1, 2, u32::MAX] { + assert_eq!(parse_state(&state_json(Saved { mode: *mode, failures })), saved(*mode, failures)); } + assert_eq!(parse_attempt("12-0.json", &attempt_json(*mode, NOW)), Some(Attempt { pid: 12, mode: *mode, started: NOW })); } + // without a count (an earlier build of this change wrote `pending`): none + assert_eq!(parse_state(r#"{"mode":"dx12","pending":true}"#), saved(Mode::Dx12, 0)); for bad in [ "", "{", "null", "[]", - r#"{"mode":"vulkan","pending":true}"#, - r#"{"mode":"dx12"}"#, - r#"{"mode":1,"pending":true}"#, - r#"{"mode":"dx12","pending":"yes"}"#, + r#"{"mode":"vulkan","failures":1}"#, + r#"{"failures":1}"#, + r#"{"mode":1,"failures":1}"#, + r#"{"mode":"dx12","failures":"one"}"#, + r#"{"mode":"dx12","failures":-1}"#, + r#"{"mode":"dx12","failures":4294967296}"#, ] { assert_eq!(parse_state(bad), None, "{bad}"); } + let good = attempt_json(Mode::Default, NOW); + for (name, text) in [ + ("12-0.json", "{"), + ("12-0.json", r#"{"mode":"default"}"#), + ("12-0.json", r#"{"mode":"default","started":-5}"#), + ("x-0.json", good.as_str()), + ("12.json", good.as_str()), + ("-0.json", good.as_str()), + ("99999999999-0.json", good.as_str()), + ("12-0.txt", good.as_str()), + ] { + assert_eq!(parse_attempt(name, text), None, "{name} {text}"); + } } fn temp_dir(name: &str) -> PathBuf { @@ -317,65 +624,198 @@ mod tests { dir } + fn state_of(dir: &Path) -> Option { + read_small(&dir.join(STATE_FILE)).and_then(|t| parse_state(&t)) + } + + fn attempt_files(dir: &Path) -> usize { + std::fs::read_dir(dir.join(ATTEMPTS_DIR)).map_or(0, |d| d.filter_map(Result::ok).count()) + } + + /// A launch as another process (`pid`) at `now`. + fn launch(dir: &Path, pid: u32, now: u64) -> GraphicsStart { + GraphicsStart::begin_at(Some(dir), false, None, now, pid) + } + #[test] - fn begin_marks_the_start_pending_and_confirm_clears_it() { - let dir = temp_dir("marker"); - let file = dir.join(STATE_FILE); - let start = GraphicsStart::begin(Some(&dir), false); + fn a_start_that_paints_keeps_its_mode_and_clears_its_attempt() { + let dir = temp_dir("paint"); + let start = launch(&dir, 10, NOW); assert_eq!(start.mode(), Mode::Default); - assert_eq!(read_state(&file), Some((Mode::Default, true))); + assert!(!start.degraded()); + assert_eq!(state_of(&dir), saved(Mode::Default, 0)); + assert_eq!(attempt_files(&dir), 1); start.confirm(); - assert_eq!(read_state(&file), Some((Mode::Default, false))); - // a confirmed start is kept on the next launch - assert_eq!(GraphicsStart::begin(Some(&dir), false).mode(), Mode::Default); - // ... and one that never confirmed moves on (Windows and Linux; macOS has nowhere to go) - let next = GraphicsStart::begin(Some(&dir), false); - assert_eq!(next.mode(), LADDER.get(1).copied().unwrap_or(Mode::Default)); + assert_eq!(attempt_files(&dir), 0); + // a window closed before it painted is no failure either + let start = launch(&dir, 11, NOW + 60); + start.finish(); + assert_eq!(attempt_files(&dir), 0); + assert_eq!(launch(&dir, 12, NOW + 120).mode(), Mode::Default); + assert_eq!(state_of(&dir), saved(Mode::Default, 0)); let _ = std::fs::remove_dir_all(&dir); } #[test] - fn a_corrupt_or_huge_marker_starts_with_the_default() { + fn two_dead_starts_move_on_and_a_painted_one_resets_the_count() { + let dir = temp_dir("dead"); + let second = LADDER.get(1).copied().unwrap_or(Mode::Default); + // dies without painting + let _ = launch(&dir, 20, NOW); + // the next launch counts it, tries the same mode again and dies too + let again = launch(&dir, 21, NOW + 60); + assert_eq!(again.mode(), Mode::Default); + assert_eq!(state_of(&dir), saved(Mode::Default, 1)); + // two in a row: the next mode, kept + let moved = launch(&dir, 22, NOW + 120); + assert_eq!(moved.mode(), second); + assert_eq!(moved.degraded(), second != Mode::Default); + moved.confirm(); + assert_eq!(launch(&dir, 23, NOW + 180).mode(), second); + // one failure, then a start that paints: the count starts over + let _ = std::fs::remove_dir_all(&dir); + let _ = launch(&dir, 30, NOW); + let painted = launch(&dir, 31, NOW + 60); + painted.confirm(); + assert_eq!(state_of(&dir), saved(Mode::Default, 0)); + let _ = launch(&dir, 32, NOW + 120); + assert_eq!(launch(&dir, 33, NOW + 180).mode(), Mode::Default); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn concurrent_launches_neither_count_nor_disturb_each_other() { + let dir = temp_dir("race"); + // two instances start within the same seconds; neither has painted yet + let a = launch(&dir, 40, NOW); + let b = launch(&dir, 41, NOW + 1); + let c = launch(&dir, 42, NOW + 2); + assert_eq!((a.mode(), b.mode(), c.mode()), (Mode::Default, Mode::Default, Mode::Default)); + assert_eq!(state_of(&dir), saved(Mode::Default, 0)); + assert_eq!(attempt_files(&dir), 3, "each instance has its own attempt file"); + b.confirm(); + a.confirm(); + c.confirm(); + assert_eq!(attempt_files(&dir), 0); + assert_eq!(launch(&dir, 43, NOW + 60).mode(), Mode::Default); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn corrupt_huge_or_foreign_files_start_with_the_default() { let dir = temp_dir("corrupt"); - let file = dir.join(STATE_FILE); - let _ = std::fs::create_dir_all(&dir); - for text in ["garbage".to_string(), format!(r#"{{"mode":"dx12","pending":true,"pad":"{}"}}"#, "x".repeat(10_000))] { - let _ = std::fs::write(&file, text); - let start = GraphicsStart::begin(Some(&dir), false); + let _ = std::fs::create_dir_all(dir.join(ATTEMPTS_DIR)); + let huge = format!(r#"{{"mode":"software","failures":1,"pad":"{}"}}"#, "x".repeat(10_000)); + for text in ["garbage", huge.as_str()] { + let _ = std::fs::write(dir.join(STATE_FILE), text); + let _ = std::fs::write(dir.join(ATTEMPTS_DIR).join("50-0.json"), text); + let _ = std::fs::write(dir.join(ATTEMPTS_DIR).join("notes.txt"), text); + let start = launch(&dir, 51, NOW); assert_eq!(start.mode(), Mode::Default); - // and it is replaced with a well-formed pending marker - assert_eq!(read_state(&file), Some((Mode::Default, true))); + // replaced with a well-formed state; the unreadable attempt is gone, other files stay + assert_eq!(state_of(&dir), saved(Mode::Default, 0)); + assert!(!dir.join(ATTEMPTS_DIR).join("50-0.json").exists()); + assert!(dir.join(ATTEMPTS_DIR).join("notes.txt").exists()); + start.finish(); } - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn an_explicit_backend_leaves_the_marker_alone() { - let dir = temp_dir("explicit"); - let file = dir.join(STATE_FILE); - let _ = std::fs::create_dir_all(&dir); - let _ = write_state(&file, Mode::Software, true); - let mut start = GraphicsStart::begin(Some(&dir), true); + // a data folder that is a file: nothing can be written, the default still starts + let file = dir.join("not-a-folder"); + let _ = std::fs::write(&file, "x"); + let mut start = GraphicsStart::begin_at(Some(&file), false, None, NOW, 52); assert_eq!(start.mode(), Mode::Default); start.confirm(); let _ = start.step_down(); - assert_eq!(read_state(&file), Some((Mode::Software, true))); - // no data folder: nothing is written, the default still starts - assert_eq!(GraphicsStart::begin(None, false).mode(), Mode::Default); + assert!(start.reset_to_default().is_err()); let _ = std::fs::remove_dir_all(&dir); } #[test] - fn step_down_walks_the_ladder_and_records_each_attempt() { + fn many_attempt_files_are_bounded_per_launch() { + let dir = temp_dir("many"); + let attempts = dir.join(ATTEMPTS_DIR); + let _ = std::fs::create_dir_all(&attempts); + let n = MAX_ATTEMPTS + 10; + for pid in 0..n { + let _ = std::fs::write(attempts.join(format!("{}-0.json", pid + 1000)), attempt_json(Mode::Software, NOW + 1)); + } + // all of them belong to instances still starting: none is counted or removed + let start = launch(&dir, 60, NOW); + assert_eq!(start.mode(), Mode::Default); + assert_eq!(attempt_files(&dir), n + 1); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn graphics_flag_picks_the_mode_and_default_undoes_a_fallback() { + let dir = temp_dir("forced"); + let last = LADDER.last().copied().unwrap_or(Mode::Default); + let _ = write_atomic(&dir.join(STATE_FILE), &state_json(Saved { mode: last, failures: 1 })); + launch(&dir, 70, NOW).confirm(); + // `--graphics default` + let start = GraphicsStart::begin_at(Some(&dir), false, Some(Mode::Default), NOW + 60, 71); + assert_eq!(start.mode(), Mode::Default); + assert_eq!(state_of(&dir), saved(Mode::Default, 0)); + start.confirm(); + assert_eq!(launch(&dir, 72, NOW + 120).mode(), Mode::Default); + // `--graphics ` is kept for the next launches + GraphicsStart::begin_at(Some(&dir), false, Some(last), NOW + 180, 73).confirm(); + assert_eq!(launch(&dir, 74, NOW + 240).mode(), last); + // a mode this platform has not starts with the first one + if !LADDER.contains(&Mode::Dx12) { + assert_eq!(GraphicsStart::begin_at(Some(&dir), false, Some(Mode::Dx12), NOW + 300, 75).mode(), Mode::Default); + } + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn reset_to_default_wins_over_a_later_confirm() { + let dir = temp_dir("reset"); + let last = LADDER.last().copied().unwrap_or(Mode::Default); + let _ = write_atomic(&dir.join(STATE_FILE), &state_json(Saved { mode: last, failures: 0 })); + let start = launch(&dir, 80, NOW); + assert_eq!(start.mode(), last); + // the notice's button, from a clone of the start (the app holds one) + assert_eq!(start.clone().reset_to_default(), Ok(())); + assert_eq!(state_of(&dir), saved(Mode::Default, 0)); + start.confirm(); + assert_eq!(state_of(&dir), saved(Mode::Default, 0)); + assert_eq!(launch(&dir, 81, NOW + 60).mode(), Mode::Default); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn an_explicit_backend_leaves_the_data_folder_alone() { + let dir = temp_dir("explicit"); + let _ = write_atomic(&dir.join(STATE_FILE), &state_json(Saved { mode: Mode::Software, failures: 1 })); + let mut start = GraphicsStart::begin_at(Some(&dir), true, None, NOW, 90); + assert_eq!(start.mode(), Mode::Default); + start.confirm(); + let _ = start.step_down(); + assert_eq!(start.reset_to_default(), Ok(())); + assert_eq!(state_of(&dir), saved(Mode::Software, 1)); + assert_eq!(attempt_files(&dir), 0); + // with --graphics too, that mode starts, still without touching the folder + let last = LADDER.last().copied().unwrap_or(Mode::Default); + assert_eq!(GraphicsStart::begin_at(Some(&dir), true, Some(last), NOW, 91).mode(), last); + assert_eq!(state_of(&dir), saved(Mode::Software, 1)); + // no data folder: nothing is written, the default still starts + assert_eq!(GraphicsStart::begin_at(None, false, None, NOW, 92).mode(), Mode::Default); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn step_down_walks_the_ladder_and_saves_each_mode_at_once() { let dir = temp_dir("step"); - let file = dir.join(STATE_FILE); - let mut start = GraphicsStart::begin(Some(&dir), false); + let mut start = launch(&dir, ME, NOW); for expected in LADDER.iter().skip(1) { assert!(start.step_down()); assert_eq!(start.mode(), *expected); - assert_eq!(read_state(&file), Some((*expected, true))); + assert_eq!(state_of(&dir), saved(*expected, 0)); + assert_eq!(attempt_files(&dir), 1, "one attempt file per process"); } assert!(!start.step_down(), "nothing after the last mode"); + start.finish(); + assert_eq!(attempt_files(&dir), 0); let _ = std::fs::remove_dir_all(&dir); } diff --git a/apps/filmcraft/src/main.rs b/apps/filmcraft/src/main.rs index 0501d313a..726abf607 100644 --- a/apps/filmcraft/src/main.rs +++ b/apps/filmcraft/src/main.rs @@ -120,10 +120,11 @@ fn main() -> eframe::Result { // OS hardware video decoders (VideoToolbox on macOS) in front of our own; Settings ▸ Playback ▸ // Hardware decoding switches them off. Unsupported streams and failures use our decoders. register_hardware_decoders(); - // Which graphics backends and adapter the window starts with: the mode after the last one - // if the previous start died before its window painted (a driver crash), see graphics.rs. + // Which graphics backends and adapter the window starts with: `--graphics`, else the saved + // mode, which moves on after starts that died before their window painted (a driver crash), + // see graphics.rs. let backend_override = eframe::wgpu::Backends::from_env(); - let mut graphics = graphics::GraphicsStart::begin(data_root.as_deref(), backend_override.is_some()); + let mut graphics = graphics::GraphicsStart::begin(data_root.as_deref(), backend_override.is_some(), launch.graphics); // The GPU export renderer creates its own wgpu instance with every backend; after a start // fell back, that would load the driver that failed. Exports then composite on the CPU. if graphics.mode() == graphics::Mode::Default { @@ -149,6 +150,8 @@ fn main() -> eframe::Result { } break result; }; + // However it ended, this start no longer counts as one that died. + graphics.finish(); // The window could not be created (no usable GPU / graphics driver, no display): say so // instead of exiting silently, and keep the reason in the crash log. if let Err(e) = &started { @@ -184,9 +187,14 @@ fn native_options(agent: bool) -> eframe::NativeOptions { /// Creates the app once eframe has its window and graphics device; sets `created` when it runs, /// and confirms the graphics mode once two frames have painted. fn app_creator(launch: Launch, graphics: graphics::GraphicsStart, created: Arc) -> eframe::AppCreator<'static> { - let Launch { control_port, control_input_only, files, demo, startup_flag, recover, data_dir } = launch; + let Launch { control_port, control_input_only, files, demo, startup_flag, recover, data_dir, graphics: _ } = launch; Box::new(move |cc| { created.store(true, Ordering::Relaxed); + // A fallback graphics mode says so in the window, with a button back to the default. + let notice = graphics.degraded().then(|| { + let start = graphics.clone(); + filmcraft_ui_egui::graphics_notice::GraphicsNotice::new(graphics.mode().label(), Box::new(move || start.reset_to_default())) + }); let confirmed = AtomicBool::new(false); cc.egui_ctx.on_end_pass( "graphics-start", @@ -240,6 +248,7 @@ fn app_creator(launch: Launch, graphics: graphics::GraphicsStart, created: Arc Result<(), String>>; + +/// A fallback graphics mode, as the host reports it. +pub struct GraphicsNotice { + /// The mode's English name ("DirectX 12 only", "software rendering"), translated when shown. + pub mode: String, + reset: ResetFn, + /// The default mode is saved for the next start. + pub reset_done: bool, + /// Closed for this session. + pub closed: bool, +} + +impl GraphicsNotice { + pub fn new(mode: &str, reset: ResetFn) -> Self { + Self { mode: mode.to_string(), reset, reset_done: false, closed: false } + } + + /// Use Default Graphics: save the default mode for the next start. + pub fn reset(&mut self) -> Result<(), String> { + (self.reset)()?; + self.reset_done = true; + Ok(()) + } +} + +/// Draw the notice (bottom right, above the status bar) until it is closed. +pub(crate) fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { + let Some(mut notice) = app.graphics_notice.take() else { return }; + if notice.closed { + app.graphics_notice = Some(notice); + return; + } + let title = tlf!("Safe graphics mode: {mode}", mode = i18n::t(¬ice.mode)); + let (mut reset, mut close) = (false, false); + let shown = egui::Window::new("graphics-notice") + .id(egui::Id::new("graphics-notice")) + .title_bar(false) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::RIGHT_BOTTOM, [-12.0, -32.0]) + .show(ctx, |ui| { + ui.set_max_width(360.0); + ui.label(egui::RichText::new(&title).strong()); + ui.add_space(4.0); + if notice.reset_done { + ui.label(tl!("The default graphics are used from the next start.")); + } else { + ui.label(tl!("An earlier start failed with the default graphics. Updating the graphics driver usually fixes this.")); + } + ui.add_space(6.0); + ui.horizontal(|ui| { + if !notice.reset_done { + let label = tl!("Use Default Graphics"); + let r = ui.button(label); + app.auto.add_response("graphics.notice.reset", &r, label); + reset = r.clicked(); + } + let label = tl!("Close"); + let c = ui.button(label); + app.auto.add_response("graphics.notice.close", &c, label); + close = c.clicked(); + }); + }); + if let Some(shown) = shown { + app.auto.add("graphics.notice", shown.response.rect, &title); + } + if reset && let Err(e) = notice.reset() { + app.ui.status = e; + } + notice.closed |= close; + app.graphics_notice = Some(notice); +} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index cdf8ed2c1..b8556532c 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3562,3 +3562,9 @@ 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. Use Effect Shutter Angle Usar el ángulo de obturación del efecto + Safe graphics mode: {mode} Modo gráfico seguro: {mode} + An earlier start failed with the default graphics. Updating the graphics driver usually fixes this. Un inicio anterior falló con los gráficos predeterminados. Actualizar el controlador gráfico suele solucionarlo. + Use Default Graphics Usar gráficos predeterminados + The default graphics are used from the next start. Los gráficos predeterminados se usarán a partir del próximo inicio. + DirectX 12 only Solo DirectX 12 + software rendering renderizado por software diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 5f17a4f44..a45b61500 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3545,3 +3545,9 @@ 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 + Safe graphics mode: {mode} Mode graphique sans échec : {mode} + An earlier start failed with the default graphics. Updating the graphics driver usually fixes this. Un démarrage précédent a échoué avec les graphismes par défaut. Mettre à jour le pilote graphique résout généralement le problème. + Use Default Graphics Utiliser les graphismes par défaut + The default graphics are used from the next start. Les graphismes par défaut seront utilisés au prochain démarrage. + DirectX 12 only DirectX 12 uniquement + software rendering rendu logiciel diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 2c160f4b6..0fdd23c8e 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3544,3 +3544,9 @@ Edit Project Notes プロジェクトメモを編集 Notes for this project: to-dos, instructions, frame numbers… Saved with the project. このプロジェクトのメモ: やること、指示、フレーム番号など… プロジェクトと一緒に保存されます。 GPU acceleration is unavailable; preview renders on the CPU GPU アクセラレーションを利用できません。プレビューは CPU でレンダリングされます + Safe graphics mode: {mode} セーフグラフィックモード: {mode} + An earlier start failed with the default graphics. Updating the graphics driver usually fixes this. 以前の起動がデフォルトのグラフィックで失敗しました。グラフィックドライバーを更新すると、通常は解決します。 + Use Default Graphics デフォルトのグラフィックを使用 + The default graphics are used from the next start. 次回の起動からデフォルトのグラフィックが使用されます。 + DirectX 12 only DirectX 12 のみ + software rendering ソフトウェアレンダリング diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index 2e6a688b5..b5b12ec50 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -3540,3 +3540,9 @@ Delete Caption Track 删除字幕轨道 Delete Caption Tracks 删除多个字幕轨道 Delete Empty Caption Tracks 删除空字幕轨道 + Safe graphics mode: {mode} 安全图形模式:{mode} + An earlier start failed with the default graphics. Updating the graphics driver usually fixes this. 上次启动时默认图形模式失败。更新显卡驱动程序通常可以解决此问题。 + Use Default Graphics 使用默认图形 + The default graphics are used from the next start. 下次启动时将使用默认图形。 + DirectX 12 only 仅 DirectX 12 + software rendering 软件渲染 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 438112a29..76b024d10 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -39,6 +39,7 @@ pub mod dock; mod dock_shared; mod dock_ui; pub mod frames; +pub mod graphics_notice; pub mod header; pub mod i18n; pub mod icons; @@ -302,6 +303,9 @@ pub struct FilmcraftApp { styled: bool, /// A panic in the UI pass, shown in an error window until dismissed (see [`crash`]). pub ui_error: Option, + /// The window runs in a fallback graphics mode (set by the desktop host): a notice with a + /// button back to the default mode (see [`graphics_notice`]). + pub graphics_notice: Option, /// Fault injection for robustness tests: the next UI pass panics. #[doc(hidden)] pub panic_next_frame: bool, @@ -546,6 +550,7 @@ impl FilmcraftApp { bindings_rev: 0, shortcut_editor: Default::default(), toast: None, + graphics_notice: None, tl: Default::default(), command_inbox: None, v_down: false, @@ -1935,6 +1940,10 @@ impl eframe::App for FilmcraftApp { self.playback.playing = false; } self.error_window(ui.ctx()); + if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| graphics_notice::show(self, ui.ctx()))).is_err() { + // a broken notice must not take the session down: drop it + self.graphics_notice = None; + } let ctx = ui.ctx().clone(); self.last_ui_time = ctx.input(|i| i.time); if !self.synthetic.is_empty() { diff --git a/crates/ui-egui/tests/graphics_notice_ui.rs b/crates/ui-egui/tests/graphics_notice_ui.rs new file mode 100644 index 000000000..272eecb3b --- /dev/null +++ b/crates/ui-egui/tests/graphics_notice_ui.rs @@ -0,0 +1,123 @@ +//! The fallback graphics notice (#820): shown while the desktop app runs in a fallback graphics +//! mode, its button saves the default mode for the next start, and it can be closed. + +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::FilmcraftApp; +use filmcraft_ui_egui::control::ControlRequest; +use filmcraft_ui_egui::graphics_notice::GraphicsNotice; +use serde_json::{Value, json}; + +struct Driver { + harness: Harness<'static, FilmcraftApp>, + tx: Sender, +} + +impl Driver { + fn new(notice: Option) -> Self { + let (tx, rx) = channel(); + let mut app = FilmcraftApp::new(Session::default()).with_control(rx); + app.graphics_notice = notice; + 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 call(&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() { + return v; + } + } + panic!("no reply to {method} {params}"); + } + fn ids(&mut self) -> Vec { + let v = self.call("ui.elements", json!({"prefix": "graphics.notice"})); + assert_eq!(v["ok"], json!(true), "{v}"); + v["result"].as_array().unwrap().iter().map(|e| e["id"].as_str().unwrap().to_string()).collect() + } + fn label(&mut self, id: &str) -> String { + let v = self.call("ui.elements", json!({"prefix": id})); + v["result"].as_array().unwrap().iter().find(|e| e["id"] == id).map(|e| e["label"].as_str().unwrap().to_string()).unwrap_or_default() + } + fn click(&mut self, id: &str) { + let v = self.call("ui.click", json!({"id": id})); + assert_eq!(v["ok"], json!(true), "{id}: {v}"); + self.frames(3); + } + fn app(&mut self) -> &mut FilmcraftApp { + self.harness.state_mut() + } +} + +#[test] +fn no_notice_in_the_default_graphics_mode() { + let mut d = Driver::new(None); + assert!(d.ids().is_empty()); +} + +#[test] +fn the_reset_button_saves_the_default_mode_once_and_close_hides_the_notice() { + let resets = Rc::new(Cell::new(0)); + let counter = Rc::clone(&resets); + let notice = GraphicsNotice::new( + "software rendering", + Box::new(move || { + counter.set(counter.get() + 1); + Ok(()) + }), + ); + let mut d = Driver::new(Some(notice)); + let ids = d.ids(); + for id in ["graphics.notice", "graphics.notice.reset", "graphics.notice.close"] { + assert!(ids.iter().any(|i| i == id), "{id} in {ids:?}"); + } + assert!(d.label("graphics.notice").contains("software rendering")); + d.click("graphics.notice.reset"); + assert_eq!(resets.get(), 1); + assert!(d.app().graphics_notice.as_ref().is_some_and(|n| n.reset_done)); + // done: the button is gone, the notice says when it takes effect + let ids = d.ids(); + assert!(!ids.iter().any(|i| i == "graphics.notice.reset"), "{ids:?}"); + assert!(ids.iter().any(|i| i == "graphics.notice")); + d.click("graphics.notice.close"); + assert!(d.ids().is_empty()); + assert_eq!(resets.get(), 1); +} + +#[test] +fn a_reset_that_cannot_be_saved_says_why_and_keeps_the_button() { + let notice = GraphicsNotice::new("DirectX 12 only", Box::new(|| Err("can't write graphics-start.json: read-only".into()))); + let mut d = Driver::new(Some(notice)); + d.click("graphics.notice.reset"); + assert!(d.app().ui.status.contains("read-only"), "{}", d.app().ui.status); + assert!(d.app().graphics_notice.as_ref().is_some_and(|n| !n.reset_done)); + assert!(d.ids().iter().any(|i| i == "graphics.notice.reset")); +} + +#[test] +fn the_notice_follows_the_interface_language() { + let notice = GraphicsNotice::new("software rendering", Box::new(|| Ok(()))); + let mut d = Driver::new(Some(notice)); + d.app().ui.language = filmcraft_ui_egui::i18n::Language::Es; + d.frames(3); + assert_eq!(d.label("graphics.notice"), "Modo gráfico seguro: renderizado por software"); + assert_eq!(d.label("graphics.notice.reset"), "Usar gráficos predeterminados"); + filmcraft_ui_egui::i18n::set_current(filmcraft_ui_egui::i18n::Language::En); +} diff --git a/docs/gaps.md b/docs/gaps.md index 9821d1d23..5101f1321 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -79,11 +79,13 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** ## G4. Crashes at start-up, and no test CI on pull requests -- **Have:** a graphics start-up fallback (#820): a start that dies before its window paints (a - driver crash in wgpu instance / device creation) leaves a marker in the data folder, and the next - launch moves on to DirectX 12 only (Windows), then a software adapter (WARP, lavapipe, llvmpipe) - with CPU compositing, and keeps the mode that works; a start-up error wgpu reports moves on in - the same process. An explicit `WGPU_BACKEND` stays in charge. +- **Have:** a graphics start-up fallback (#820): after two starts in a row that died within 15 s + of launch without painting (a driver crash in wgpu instance / device creation; hangs, later + crashes and concurrent instances don't count), the next launch moves on to DirectX 12 only + (Windows), then a software adapter (WARP, lavapipe, llvmpipe) with CPU compositing, and keeps + the mode that works; a start-up error wgpu reports moves on in the same process. A fallback mode + shows a notice with Use Default Graphics; `--graphics default|dx12|software` picks a mode by + hand. An explicit `WGPU_BACKEND` stays in charge. - **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 @@ -93,8 +95,7 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** 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~~ (done, #820; a way back to the default mode from Settings is - still missing: today the marker file is deleted by hand), a PR workflow running `cargo xtask ci` + start when the GPU adapter fails~~ (done, #820), 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 From 14b5f428d9fe97b3131b12296f27ac8cab4c6c48 Mon Sep 17 00:00:00 2001 From: Takahiro Miki Date: Sun, 11 Oct 2026 00:20:20 -0700 Subject: [PATCH 197/260] timeline: a drag with Track Select Forward/Backward moves what it selects With the Track Select Forward tool (A) a press selected every clip from the pointer on, but a drag never started, so the clips could not be moved with the tool as in Premiere Pro. A drag now starts a move of the selection, with Option copying as with the Selection tool. A press on a transition over a cut did nothing with these tools; it now counts as its track, as the clips under it do. Regression test: track_select_drag_ui (the drag test fails before the fix). --- crates/ui-egui/src/panels/timeline.rs | 14 ++- crates/ui-egui/tests/track_select_drag_ui.rs | 116 +++++++++++++++++++ 2 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/tests/track_select_drag_ui.rs diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index d5766c0bb..f1b408b45 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2079,7 +2079,11 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & } None } - (Tool::TrackSelectForward | Tool::TrackSelectBackward, Grab::Other(Hit::Clip { track, .. } | Hit::Empty { track })) => { + // a transition over a cut counts as its track, as the clips under it do + ( + Tool::TrackSelectForward | Tool::TrackSelectBackward, + Grab::Other(Hit::Clip { track, .. } | Hit::Transition { track, .. } | Hit::Empty { track }), + ) => { let fwd = tool == Tool::TrackSelectForward; let ids: Vec = seq .all_tracks() @@ -2089,7 +2093,13 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & .map(|i| i.id.0) .collect(); let _ = app.session.execute("timeline.select", json!({"clips": ids})); - None + // as in Premiere Pro, a drag with the tool moves what it just selected + if resp.drag_started() { + let clips = app.session.state.selection.clone(); + (!clips.is_empty()).then_some(Drag::Move { clips, grab_tick: t, start_track: track, offset: Tick::ZERO, track_delta: 0, copy: mods.alt }) + } else { + None + } } (Tool::Selection | Tool::Ripple | Tool::Rolling, Grab::Edge { clip, edge, track, .. }) if resp.clicked() => { // click an edge = select it as an edit point (trim mode); Shift adds diff --git a/crates/ui-egui/tests/track_select_drag_ui.rs b/crates/ui-egui/tests/track_select_drag_ui.rs new file mode 100644 index 000000000..0daf0ee8b --- /dev/null +++ b/crates/ui-egui/tests/track_select_drag_ui.rs @@ -0,0 +1,116 @@ +//! The Track Select Forward tool (A) selects every clip from the pointer on, and a drag with it +//! moves them all (Premiere); before, the drag only selected and nothing moved. What it selected +//! also moves with a Selection-tool drag afterwards. + +use std::collections::HashMap; +use std::sync::mpsc::{Sender, channel}; + +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_time::Tick; +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 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]] + } + /// Every clip's id → (start, end). + fn spans(&mut self) -> HashMap { + let q = self.harness.state().session.active_sequence().unwrap(); + q.all_tracks().flat_map(|t| t.items.iter()).map(|i| (i.id.0, (i.start, i.end()))).collect() + } +} + +/// The middle of the second clip on V1 (its ends carry transitions), and that clip's start: the clips before it stay put. +fn second_v1_clip(d: &mut Driver) -> (f64, f64, Tick) { + let q = d.harness.state().session.active_sequence().unwrap().clone(); + let c = q.video_tracks[0].items.get(1).expect("a second clip on V1").clone(); + let r = d.rect(&format!("timeline.clip.{}", c.id.0)); + (r[0] + r[2] / 2.0, r[1] + r[3] / 2.0, c.start) +} + +/// Every clip that starts at or after `from` moved by the same positive offset; every clip that +/// ends at or before it stayed. +fn assert_moved_from(before: &HashMap, after: &HashMap, from: Tick) { + let mut offset = None; + for (id, &(s, e)) in before { + let (s2, _) = after[id]; + if s >= from { + let o = s2 - s; + assert!(o > Tick::ZERO, "clip {id} at or after the click moved right"); + assert_eq!(*offset.get_or_insert(o), o, "clip {id} moved with the rest"); + } else if e <= from { + assert_eq!(s2, s, "clip {id} before the click stayed"); + } + } + assert!(offset.is_some(), "there were clips after the click"); +} + +#[test] +fn a_drag_with_track_select_forward_moves_every_clip_after_the_pointer() { + let mut d = Driver::demo(); + let (x, y, from) = second_v1_clip(&mut d); + let before = d.spans(); + d.ok("ui.set", json!({"tool": "TrackSelectForward"})); + d.ok("ui.drag", json!({"from": {"x": x, "y": y}, "to": {"x": x + 240.0, "y": y}, "steps": 12})); + d.frames(3); + let after = d.spans(); + assert_moved_from(&before, &after, from); +} + +#[test] +fn clips_selected_with_track_select_forward_move_with_the_selection_tool() { + let mut d = Driver::demo(); + let (x, y, from) = second_v1_clip(&mut d); + let before = d.spans(); + d.ok("ui.set", json!({"tool": "TrackSelectForward"})); + d.ok("ui.click", json!({"x": x, "y": y})); + d.frames(2); + assert!(d.harness.state().session.state.selection.len() > 1, "the click selected the clips after it"); + d.ok("ui.set", json!({"tool": "Selection"})); + d.ok("ui.drag", json!({"from": {"x": x, "y": y}, "to": {"x": x + 240.0, "y": y}, "steps": 12})); + d.frames(3); + let after = d.spans(); + assert_moved_from(&before, &after, from); +} From d348dccd21a257c610d21c14636506021a331521 Mon Sep 17 00:00:00 2001 From: JordyUSA <75485565+JordyUSA@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:26:28 +0700 Subject: [PATCH 198/260] #922: eight-channel AAC reports and returns eight channels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MP4 reader took the AudioSpecificConfig's channel configuration as a count, but it is an index into ISO/IEC 14496-3 Table 1.19: configuration 7 (7.1) reported seven channels and dropped the last decoded one, and 12 (7.1 back, what ffmpeg 9 writes for 7.1) reported twelve. Count from the table (with Amd 4: 11 → 7, 12 and 14 → 8, 13 → 24), and decode configurations 11 and 12, reordered to the WAV/SMPTE order used for 5.1. Checked against ffmpeg's decode channel by channel. Co-Authored-By: Claude Opus 5.5 --- crates/aac/README.md | 3 + crates/aac/src/config.rs | 14 ++- crates/codecs/src/audio.rs | 5 +- crates/codecs/src/mp4.rs | 3 +- crates/codecs/tests/aac_channels_oracle.rs | 108 +++++++++++++++++++++ crates/isobmff/src/codec.rs | 15 ++- 6 files changed, 142 insertions(+), 6 deletions(-) create mode 100644 crates/codecs/tests/aac_channels_oracle.rs diff --git a/crates/aac/README.md b/crates/aac/README.md index d2b7e63d3..ffd5bf571 100644 --- a/crates/aac/README.md +++ b/crates/aac/README.md @@ -48,6 +48,9 @@ from bitrate/quality), `tns`, `ms_stereo`, `window_shape` (sine/KBD), `adts` (pr | 7 | 0 + PCE | C L R Ls Rs Cs LFE (PCE in the ASC; repeated in every AU when ADTS-framed) | | 8 | 7 | C Lc Rc L R Ls Rs LFE | +The decoder also reads configurations 11 (6.1: C L R Ls Rs Cs LFE) and 12 (7.1: C L R Ls Rs Lsr Rsr +LFE) from ISO/IEC 14496-3 Amd 4; ffmpeg 9 writes 7.1 AAC as configuration 12. + `Decoder::layout()` reports the element layout (it follows a PCE for configuration 0). ## Encoder diff --git a/crates/aac/src/config.rs b/crates/aac/src/config.rs index d0da2f350..f0b326d0a 100644 --- a/crates/aac/src/config.rs +++ b/crates/aac/src/config.rs @@ -136,7 +136,8 @@ impl ProgramConfig { } } -/// Element layout of `channelConfiguration` 1..=7 (tags as conventionally assigned). +/// Element layout of `channelConfiguration` 1..=7, 11 and 12 (tags as conventionally assigned; 11 and +/// 12 from ISO/IEC 14496-3 Amd 4: 6.1 with a back centre, 7.1 with back surrounds). pub fn config_layout(channel_config: u8) -> Option> { use ElementType::*; Some(match channel_config { @@ -146,7 +147,8 @@ pub fn config_layout(channel_config: u8) -> Option> { 4 => vec![(Sce, 0), (Cpe, 0), (Sce, 1)], 5 => vec![(Sce, 0), (Cpe, 0), (Cpe, 1)], 6 => vec![(Sce, 0), (Cpe, 0), (Cpe, 1), (Lfe, 0)], - 7 => vec![(Sce, 0), (Cpe, 0), (Cpe, 1), (Cpe, 2), (Lfe, 0)], + 7 | 12 => vec![(Sce, 0), (Cpe, 0), (Cpe, 1), (Cpe, 2), (Lfe, 0)], + 11 => vec![(Sce, 0), (Cpe, 0), (Cpe, 1), (Sce, 1), (Lfe, 0)], _ => return None, }) } @@ -388,6 +390,14 @@ mod tests { assert_eq!(AudioSpecificConfig::parse(&a.to_bytes()).unwrap().channels(), 6); } + #[test] + fn amd4_channel_configurations() { + // 6.1 and 7.1 back (#922); 22.2 and 7.1 with heights stay unsupported + let channels = |c| AudioSpecificConfig::parse(&AudioSpecificConfig::lc(3, c).to_bytes()).map(|a| a.channels()).ok(); + assert_eq!([7, 11, 12].map(channels), [Some(8), Some(7), Some(8)]); + assert_eq!([13, 14].map(config_layout), [None, None]); + } + #[test] fn asc_with_pce() { let pce = ProgramConfig { diff --git a/crates/codecs/src/audio.rs b/crates/codecs/src/audio.rs index daba8f4cd..e0bc3f139 100644 --- a/crates/codecs/src/audio.rs +++ b/crates/codecs/src/audio.rs @@ -59,13 +59,16 @@ 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. +/// unambiguous channel configurations 3–6, 11 (6.1: … LFE, back centre, sides) and 12 (7.1: … LFE, +/// backs, sides); 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], + 11 => &[1, 2, 0, 6, 5, 3, 4], + 12 => &[1, 2, 0, 7, 5, 6, 3, 4], _ => return None, }) } diff --git a/crates/codecs/src/mp4.rs b/crates/codecs/src/mp4.rs index c7ea9d069..2f43bce00 100644 --- a/crates/codecs/src/mp4.rs +++ b/crates/codecs/src/mp4.rs @@ -540,7 +540,8 @@ fn audio_stream_info(t: &filmcraft_isobmff::Track, bytes: &crate::Src) -> Option None => ap.sample_rate as u32, } }, - if a.channel_config > 0 { a.channel_config as u32 } else { ap.channels }, + // the configuration is an index (7 and 12 are both eight channels), not a count + if a.channels() > 0 { a.channels() } else { ap.channels }, None, ), CodecConfig::Pcm(p) => (p.sample_rate as u32, p.channels, Some(p.bits as u32)), diff --git a/crates/codecs/tests/aac_channels_oracle.rs b/crates/codecs/tests/aac_channels_oracle.rs new file mode 100644 index 000000000..5f850ac3e --- /dev/null +++ b/crates/codecs/tests/aac_channels_oracle.rs @@ -0,0 +1,108 @@ +//! Multichannel AAC in MP4 against ffmpeg (#922). The AudioSpecificConfig's channel configuration +//! is an index into ISO/IEC 14496-3 Table 1.19 (with Amd 4), not a channel count: 7 and 12 are +//! both 7.1 (eight channels) and 11 is 6.1 (seven). Each channel carries its own tone, so a +//! dropped or swapped channel shows up against ffmpeg's decode. + +mod common; + +use std::io::Cursor; + +use common::*; +use filmcraft_isobmff::{Brand, Mp4Writer, SampleEntry, TrackConfig, WriteSample, WriterOptions}; + +const RATE: u32 = 48_000; + +/// A one-second AAC MP4 of `layout` (`n` channels), channel `i` a tone of 200 + 100·i Hz, except +/// the LFE (fourth in all these layouts) at 60 Hz: AAC band-limits it. +fn tones(ff: &std::path::Path, name: &str, layout: &str, n: usize) -> Option { + let mut graph: String = + (0..n).map(|i| format!("sine=frequency={}:sample_rate={RATE}:duration=1[a{i}];", if i == 3 { 60 } else { 200 + 100 * i })).collect(); + graph.extend((0..n).map(|i| format!("[a{i}]"))); + graph.push_str(&format!("join=inputs={n}:channel_layout={layout}[out]")); + fixture(ff, name, &["-filter_complex", &graph, "-map", "[out]", "-c:a", "aac", "-b:a", "512k"]) +} + +/// The AAC access units of `path`'s audio track, rewrapped in a new MP4 whose AudioSpecificConfig +/// says `channel_config` (AAC LC, 48 kHz), keeping each sample's duration and the priming edit. +fn rewrap(path: &std::path::Path, name: &str, channel_config: u8, channels: u32) -> std::path::PathBuf { + let data = bytes(path); + let file = filmcraft_isobmff::open(&data).unwrap(); + let track = file.tracks.iter().find(|t| t.kind == filmcraft_isobmff::TrackKind::Audio).unwrap(); + // object type 2, rate index 3 (48 kHz), then the configuration + let asc = (((2u16 << 11) | (3 << 7) | ((channel_config as u16) << 3)).to_be_bytes()).to_vec(); + let mut mux = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).unwrap(); + let id = mux.add_track(TrackConfig::new(SampleEntry::aac(asc, channels, RATE), RATE)).unwrap(); + for s in &track.samples { + let au = &data[s.offset as usize..s.offset as usize + s.size as usize]; + mux.write_sample(id, WriteSample { data: au, duration: s.duration, composition_offset: 0, is_sync: true }).unwrap(); + } + let out = dir().join(name); + std::fs::write(&out, mux.finish().unwrap().into_inner()).unwrap(); + out +} + +/// Our decode of the whole file, channel by channel. +fn ours(path: &std::path::Path, name: &str) -> (u32, Vec>) { + let src = filmcraft_codecs::open_bytes(name, bytes(path)).unwrap(); + let ch = src.info().audio().map(|a| a.channels).unwrap(); + (ch, src.audio(0, RATE as usize * 9 / 10, RATE).unwrap().channels) +} + +/// ffmpeg's decode, deinterleaved into `n` channels. +fn theirs(ff: &std::path::Path, path: &std::path::Path, n: usize) -> Vec> { + let all = ffmpeg_audio_f32(ff, path, &[]); + (0..n).map(|c| all.iter().skip(c).step_by(n).copied().collect()).collect() +} + +/// Largest sample difference over the first `len` samples, best over a small lag either way (the +/// two decoders may place the priming differently by a few samples). +fn diff(a: &[f32], b: &[f32]) -> f32 { + let len = a.len().min(b.len()).saturating_sub(64); + (0..=32).flat_map(|l| [(l, 0), (0, l)]).map(|(x, y)| (32..len).map(|i| (a[i + x] - b[i + y]).abs()).fold(0.0, f32::max)).fold(f32::MAX, f32::min) +} + +fn peak(x: &[f32]) -> f32 { + x.iter().fold(0.0, |m, v| m.max(v.abs())) +} + +#[test] +fn aac_channel_configurations_report_and_decode_every_channel() { + let ff = filmcraft_testkit::require_ffmpeg!(); + // ffmpeg 8 writes 7.1 as configuration 7 and ffmpeg 9 as 12: the same five elements + let Some(seven) = tones(&ff, "aac_71.mp4", "7.1", 8) else { + eprintln!("SKIPPED: this ffmpeg can't encode 7.1 AAC"); + return; + }; + let asc = filmcraft_isobmff::open(bytes(&seven)).unwrap().tracks.iter().find_map(|t| match &t.entries.first()?.codec { + filmcraft_isobmff::CodecConfig::Aac(a) => Some(a.channel_config), + _ => None, + }); + let twelve = match asc { + Some(7) => rewrap(&seven, "aac_71_cfg12.mp4", 12, 8), + Some(12) => seven.clone(), + other => panic!("ffmpeg wrote 7.1 AAC as configuration {other:?}"), + }; + let seven = if asc == Some(7) { seven } else { rewrap(&seven, "aac_71_cfg7.mp4", 7, 8) }; + + // configuration 7: eight channels reported and returned, the last one too + let (ch, got) = ours(&seven, "aac_71_cfg7.mp4"); + assert_eq!((ch, got.len()), (8, 8), "configuration 7"); + assert!(got.iter().all(|c| peak(c) > 0.05), "configuration 7: a silent channel"); + + // configuration 12 (7.1 back): decoded, in ffmpeg's (and WAV's) L R C LFE Lb Rb Ls Rs order + let (ch, got) = ours(&twelve, "aac_71_cfg12.mp4"); + assert_eq!((ch, got.len()), (8, 8), "configuration 12"); + let want = theirs(&ff, &twelve, 8); + for (c, (a, b)) in got.iter().zip(&want).enumerate() { + let d = diff(a, b); + println!("configuration 12, channel {c}: peak {:.3}, max |ours - ffmpeg| {d:.5}", peak(a)); + assert!(peak(a) > 0.05 && d < 0.01, "configuration 12, channel {c}: off ffmpeg's decode by {d}"); + } + + // controls: 5.1 (configuration 6) and 6.1 (ffmpeg writes a PCE) + for (layout, n, name) in [("5.1", 6, "aac_51.mp4"), ("6.1", 7, "aac_61.mp4")] { + let path = tones(&ff, name, layout, n).unwrap(); + let (ch, got) = ours(&path, name); + assert_eq!((ch as usize, got.len()), (n, n), "{layout}"); + } +} diff --git a/crates/isobmff/src/codec.rs b/crates/isobmff/src/codec.rs index 6d2ff79b2..7050f74c8 100644 --- a/crates/isobmff/src/codec.rs +++ b/crates/isobmff/src/codec.rs @@ -569,11 +569,15 @@ impl AacConfig { let (object_type, sample_rate, channel_config) = parse_asc(&asc).unwrap_or((0, 0, 0)); AacConfig { asc, object_type, sample_rate, channel_config, max_bitrate: 0, avg_bitrate: 0, buffer_size: 0 } } - /// Channel count implied by the channel configuration (0 if unspecified). + /// Channel count implied by the channel configuration (ISO/IEC 14496-3 Table 1.19 with Amd 4: + /// 7 and 12 are 7.1 front / back, 11 is 6.1, 13 is 22.2, 14 is 7.1 with front heights), 0 if + /// unspecified (a PCE) or reserved. pub fn channels(&self) -> u32 { match self.channel_config { 1..=6 => self.channel_config as u32, - 7 => 8, + 11 => 7, + 7 | 12 | 14 => 8, + 13 => 24, _ => 0, } } @@ -1632,6 +1636,13 @@ mod tests { assert_eq!(OpusConfig::parse(&o.to_bytes()).unwrap(), o); } + #[test] + fn aac_channel_configuration_is_an_index() { + // 48 kHz AAC LC: configuration → channels (Table 1.19 with Amd 4; #922) + let channels = |c: u16| AacConfig::from_asc(((2 << 11) | (3 << 7) | (c << 3)).to_be_bytes().to_vec()).channels(); + assert_eq!([0, 1, 6, 7, 8, 11, 12, 13, 14, 15].map(channels), [0, 1, 6, 8, 0, 7, 8, 24, 8, 0]); + } + #[test] fn aac_esds_roundtrip() { let e = SampleEntry::aac(vec![0x12, 0x10], 2, 44100); From 424ec6014b8a447440f1bd5caf9a99f5af209535 Mon Sep 17 00:00:00 2001 From: JordyUSA <75485565+JordyUSA@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:28:42 +0700 Subject: [PATCH 199/260] #923: reordered Matroska video without DefaultDuration keeps its frame rate Without DefaultDuration the rate came from the median step between neighbouring blocks in file order. Blocks are in decode order, so with B-frames neighbours are two or more frames apart in presentation time and a 25 fps clip reported 12.5. Step between the sorted presentation times instead. Co-Authored-By: Claude Opus 5.5 --- crates/codecs/src/mkv.rs | 6 +++++- crates/codecs/src/tests.rs | 31 +++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/crates/codecs/src/mkv.rs b/crates/codecs/src/mkv.rs index 9e0b6c0ad..9d3d0c2ec 100644 --- a/crates/codecs/src/mkv.rs +++ b/crates/codecs/src/mkv.rs @@ -358,7 +358,11 @@ impl MkvSource { let rate = match t.default_duration_ns { Some(ns) if ns > 0 => FrameRate::from_f64(1e9 / ns as f64), _ => { - let mut d: Vec = t.samples.windows(2).take(240).map(|p| (p[1].pts - p[0].pts).abs()).filter(|d| *d > 0).collect(); + // blocks are stored in decode order: with B-frames, neighbours in the file are + // up to a few frames apart, so step between presentation times (#923) + let mut pts: Vec = t.samples.iter().take(241).map(|s| s.pts).collect(); + pts.sort_unstable(); + let mut d: Vec = pts.windows(2).map(|p| p[1].saturating_sub(p[0])).filter(|d| *d > 0).collect(); d.sort_unstable(); let (n, dd) = tb(t); let step = d.get(d.len() / 2).copied().unwrap_or(1).max(1) as f64 * n as f64 / dd as f64; diff --git a/crates/codecs/src/tests.rs b/crates/codecs/src/tests.rs index 751b18fdf..3aadb1809 100644 --- a/crates/codecs/src/tests.rs +++ b/crates/codecs/src/tests.rs @@ -229,6 +229,37 @@ fn matroska_prores_pcm() { check_mkv("blue_prores_pcm.mkv", &["-c:v", "prores_ks", "-profile:v", "2"], &["-c:a", "pcm_s16le"]); } +/// `bytes` with its first `DefaultDuration` element (0x23E383) overwritten by an EBML Void of the +/// same length, as a muxer that leaves the optional element out would write the track. +fn without_default_duration(bytes: &[u8]) -> Vec { + let mut out = bytes.to_vec(); + let at = out.windows(3).position(|w| w == [0x23, 0xE3, 0x83]).expect("DefaultDuration"); + // ffmpeg writes a one-byte size (0x80 | n) + let total = 4 + (out[at + 3] & 0x7F) as usize; + out[at] = 0xEC; + out[at + 1] = 0x80 | (total - 2) as u8; + out[at + 2..at + total].fill(0); + out +} + +#[test] +fn matroska_reordered_video_without_default_duration_keeps_its_rate() { + // blocks are in decode order, so B-frames put file neighbours 2–3 frames apart (#923) + for (bf, name) in [(0, "rate25_bf0.mkv"), (1, "rate25_bf1.mkv"), (2, "rate25_bf2.mkv")] { + let params = format!("bframes={bf}:b-adapt=0:b-pyramid=0:keyint=250:scenecut=0"); + let args = + ["-f", "lavfi", "-i", "testsrc2=size=64x48:rate=25", "-frames:v", "25", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-x264-params", ¶ms, "-an"]; + let Some(control) = fixture(name, &args) else { return }; + let stripped = without_default_duration(&control); + for (label, bytes) in [("with DefaultDuration", control.to_vec()), ("without", stripped)] { + let src = crate::open_bytes(name, bytes.into()).expect("open"); + let rate = src.info().video.as_ref().expect("video").frame_rate; + assert_eq!((rate.num, rate.den), (25, 1), "{bf} B-frames, {label}"); + assert_eq!(src.info().duration, Tick::from_rational(1, 1, 1), "{bf} B-frames, {label}"); + } + } +} + /// Path of a fixture under `target/fixtures/codecs/`, generated with ffmpeg when missing /// (`None` when ffmpeg or the encoder is unavailable). fn fixture_path(name: &str, args: &[&str]) -> Option { From 0166dc1d6c7c48bfdf07a59ea4873795d8470b7b Mon Sep 17 00:00:00 2001 From: JordyUSA <75485565+JordyUSA@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:35:06 +0700 Subject: [PATCH 200/260] #924: decoded H.264 / HEVC frames keep the signalled colour primaries vui_color took the VUI matrix, transfer and range but not the primaries, so every decoded H.264 / HEVC frame reported BT.709 primaries. Stream info read them, so a BT.2020 file without a colr box said BT.2020 in its info and BT.709 on its frames (Rec709 instead of Rec2020 for the renderer). Pass colour_primaries through, for the software and hardware decoders. Co-Authored-By: Claude Opus 5.5 --- crates/codecs/src/hw.rs | 4 +- crates/codecs/src/video.rs | 15 +++--- crates/codecs/tests/frame_color_oracle.rs | 61 +++++++++++++++++++++++ 3 files changed, 72 insertions(+), 8 deletions(-) create mode 100644 crates/codecs/tests/frame_color_oracle.rs diff --git a/crates/codecs/src/hw.rs b/crates/codecs/src/hw.rs index 771de6476..b73b963c4 100644 --- a/crates/codecs/src/hw.rs +++ b/crates/codecs/src/hw.rs @@ -258,7 +258,7 @@ impl NalStreamInfo { bit_depth_chroma: sps.bit_depth_chroma, interlaced: !sps.frame_mbs_only, profile_idc: sps.profile_idc, - color: vui_color(crop.2, crop.3, vui.matrix_coefficients, vui.transfer_characteristics, vui.full_range), + color: vui_color(crop.2, crop.3, vui.colour_primaries, vui.matrix_coefficients, vui.transfer_characteristics, vui.full_range), par: sar_par(vui.sar), // as the software decoder's DPB (`max_reorder`) reorder: sps.max_num_reorder_frames().min(sps.max_dpb_frames()), @@ -304,7 +304,7 @@ impl NalStreamInfo { bit_depth_chroma: sps.bit_depth_chroma, interlaced: false, profile_idc: sps.ptl.profile_idc, - color: vui_color(crop.2, crop.3, vui.matrix_coefficients, vui.transfer_characteristics, vui.full_range), + color: vui_color(crop.2, crop.3, vui.colour_primaries, vui.matrix_coefficients, vui.transfer_characteristics, vui.full_range), par: sar_par(vui.sar), reorder: sps.max_num_reorder as usize, }) diff --git a/crates/codecs/src/video.rs b/crates/codecs/src/video.rs index fa8eb9de3..e646c4744 100644 --- a/crates/codecs/src/video.rs +++ b/crates/codecs/src/video.rs @@ -140,11 +140,14 @@ pub(crate) fn tight_plane(src: Vec, stride: usize, w: usize, h: usiz } /// Colour of an H.264 / HEVC picture from its VUI code points (ITU-T H.273), as the decoders -/// report it: BT.709 by default with the matrix guessed from the size, the signalled matrix and -/// transfer when known, and full range when flagged. Shared by the software and hardware -/// decoders so the two are interchangeable. -pub fn vui_color(width: u32, height: u32, matrix: u8, transfer: u8, full_range: bool) -> filmcraft_color::ColorInfo { +/// report it: BT.709 by default with the matrix guessed from the size, the signalled primaries, +/// matrix and transfer when known, and full range when flagged. Shared by the software and +/// hardware decoders so the two are interchangeable. +pub fn vui_color(width: u32, height: u32, primaries: u8, matrix: u8, transfer: u8, full_range: bool) -> filmcraft_color::ColorInfo { let mut color = filmcraft_color::ColorInfo { matrix: filmcraft_frame::default_matrix(width, height), ..filmcraft_color::ColorInfo::REC709 }; + if let Some(p) = primaries_from_code(primaries) { + color.primaries = p; + } if let Some(m) = filmcraft_color::Matrix::from_code(matrix) { color.matrix = m; } @@ -250,7 +253,7 @@ impl H264Decoder { let y = tight_plane(p.y, p.y_stride, w, h); let u = tight_plane(p.u, p.uv_stride, cw, ch); let v = tight_plane(p.v, p.uv_stride, cw, ch); - let color = vui_color(p.width, p.height, p.color.matrix, p.color.transfer, p.color.full_range); + let color = vui_color(p.width, p.height, p.color.primaries, p.color.matrix, p.color.transfer, p.color.full_range); let par = sar_par(p.sar); let frame = VideoFrame { width: p.width, @@ -371,7 +374,7 @@ impl HevcDecoder { alpha: None, }, }; - let color = vui_color(p.width, p.height, p.color.matrix, p.color.transfer, p.color.full_range); + let color = vui_color(p.width, p.height, p.color.primaries, p.color.matrix, p.color.transfer, p.color.full_range); let par = sar_par(p.sar); let frame = VideoFrame { width: p.width, height: p.height, data, color, par, pts: filmcraft_time::Tick::ZERO }; DecodedFrame { pts: p.pts, frame, draft: p.draft } diff --git a/crates/codecs/tests/frame_color_oracle.rs b/crates/codecs/tests/frame_color_oracle.rs new file mode 100644 index 000000000..7da3baf09 --- /dev/null +++ b/crates/codecs/tests/frame_color_oracle.rs @@ -0,0 +1,61 @@ +//! Decoded H.264 / HEVC frames carry the colour primaries their VUI signals (#924). ffmpeg writes +//! MP4 without a `colr` box unless asked, so for most files the bitstream is the only place the +//! primaries are described: stream info already read them, the decoded frames reported BT.709. + +mod common; + +use common::*; +use filmcraft_color::{ColorInfo, ColorSpace, Matrix, Primaries, Range, Transfer}; +use filmcraft_media::FrameRequest; +use filmcraft_time::Tick; + +/// One 64×64 frame of `codec` with the colour `params` (x264 / x265 syntax), and our stream and +/// frame colour for it. +fn colors(ff: &std::path::Path, name: &str, codec: &[&str]) -> Option<(ColorInfo, ColorInfo)> { + let mut args = vec!["-f", "lavfi", "-i", "testsrc2=size=64x64:rate=1", "-frames:v", "1"]; + args.extend_from_slice(codec); + let path = fixture(ff, name, &args)?; + let isobmff = filmcraft_isobmff::open(bytes(&path)).unwrap(); + let track = isobmff.tracks.iter().find(|t| t.kind == filmcraft_isobmff::TrackKind::Video).unwrap(); + assert!(track.entries[0].video.as_ref().unwrap().color.is_none(), "{name}: the fixture has a colr box"); + let src = filmcraft_codecs::open_bytes(name, bytes(&path)).unwrap(); + let stream = src.info().video.as_ref().unwrap().color; + let frame = src.video_frame(FrameRequest::full(Tick::ZERO)).unwrap().color; + Some((stream, frame)) +} + +#[test] +fn decoded_frames_keep_the_signalled_primaries() { + let ff = filmcraft_testkit::require_ffmpeg!(); + let x264 = |p: &'static str| ["-c:v", "libx264", "-pix_fmt", "yuv420p", "-x264-params", p]; + let x265 = |p: &'static str, pix: &'static str| ["-c:v", "libx265", "-pix_fmt", pix, "-tag:v", "hvc1", "-x265-params", p]; + let sdr2020 = ColorInfo { matrix: Matrix::Bt709, transfer: Transfer::Bt709, primaries: Primaries::Bt2020, range: Range::Limited }; + let cases: [(&str, Vec<&str>, ColorInfo, ColorSpace); 4] = [ + ("prim2020_h264.mp4", x264("colorprim=bt2020:transfer=bt709:colormatrix=bt709").to_vec(), sdr2020, ColorSpace::Rec2020), + ("prim2020_hevc.mp4", x265("log-level=error:colorprim=bt2020:transfer=bt709:colormatrix=bt709", "yuv420p").to_vec(), sdr2020, ColorSpace::Rec2020), + // control: BT.709 throughout + ( + "prim709_h264.mp4", + x264("colorprim=bt709:transfer=bt709:colormatrix=bt709").to_vec(), + ColorInfo { primaries: Primaries::Bt709, ..sdr2020 }, + ColorSpace::Rec709, + ), + // HDR keeps its transfer and now its primaries too + ( + "prim2020_pq_hevc.mp4", + x265("log-level=error:colorprim=bt2020:transfer=smpte2084:colormatrix=bt2020nc", "yuv420p10le").to_vec(), + ColorInfo { matrix: Matrix::Bt2020Ncl, transfer: Transfer::Pq, primaries: Primaries::Bt2020, range: Range::Limited }, + ColorSpace::Rec2100Pq, + ), + ]; + for (name, codec, want, space) in cases { + let Some((stream, frame)) = colors(&ff, name, &codec) else { + eprintln!("SKIPPED ({name}): this ffmpeg can't encode it"); + continue; + }; + println!("{name}: stream {stream:?}, frame {frame:?}"); + assert_eq!(stream, want, "{name}: stream"); + assert_eq!(frame, want, "{name}: decoded frame"); + assert_eq!(ColorSpace::from_info(&frame), space, "{name}"); + } +} From a4053c07356cd2ae108521f0c56d99c1c0356e3a Mon Sep 17 00:00:00 2001 From: JordyUSA <75485565+JordyUSA@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:00:30 +0700 Subject: [PATCH 201/260] #928: the schema reports Restore open sequences as wired file.open restores the saved sequence tabs and views when timeline.restoreOpenSequences is on, but prefs.schema still marked the preference wired:false ("stored, no effect yet"). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/settings.rs | 2 +- crates/engine/src/settings_tests.rs | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/settings.rs b/crates/engine/src/settings.rs index e6f91d785..3b518fec2 100644 --- a/crates/engine/src/settings.rs +++ b/crates/engine/src/settings.rs @@ -1107,7 +1107,7 @@ static CATEGORIES: &[Category] = &[ b("timeline.playAfterRendering", "Play after rendering previews", true), b("timeline.clipMismatchWarning", "Show Clip Mismatch Warning dialog", false), b("timeline.matchFrameSetsIn", "Match frame sets in point", false), - b("timeline.restoreOpenSequences", "Restore open sequences when opening projects", false), + b("timeline.restoreOpenSequences", "Restore open sequences when opening projects", true), b("timeline.addTracksAutomatically", "Add tracks automatically when editing source clips onto the Timeline", false), b("timeline.dropImportToTimeline", "Place files dropped onto the Timeline directly on the Timeline", true), ], diff --git a/crates/engine/src/settings_tests.rs b/crates/engine/src/settings_tests.rs index 6df9e2462..3f7759539 100644 --- a/crates/engine/src/settings_tests.rs +++ b/crates/engine/src/settings_tests.rs @@ -256,6 +256,10 @@ fn schema_command_describes_every_category() { assert_eq!(auto["wired"], true); assert!(auto["kind"]["choices"].as_array().unwrap().iter().any(|c| c["value"] == "smoothScroll")); + // `file.open` restores the saved tabs and views with this on (#928) + let restore = tl["fields"].as_array().unwrap().iter().find(|f| f["key"] == "timeline.restoreOpenSequences").unwrap(); + assert_eq!((restore["wired"].clone(), restore["value"].clone()), (json!(true), json!(true))); + // 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"})); From 2781df8b26b2043ea001a0605a9d67a7d9da6ebf Mon Sep 17 00:00:00 2001 From: ReReddo <249398616+ReReddo@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:15:53 +0300 Subject: [PATCH 202/260] monitors: gentler, reversible wheel zoom The mouse wheel over the Program and Source monitors now zooms about 10% per notch instead of 40%, exponential in the wheel travel, so one notch in followed by one notch out returns to the same magnification. Pinch zoom, the 10%-1600% range and zooming about the pointer are unchanged. --- crates/ui-egui/src/panels/monitor_view.rs | 5 +++-- crates/ui-egui/tests/view_ui.rs | 18 +++++++++++++----- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/panels/monitor_view.rs b/crates/ui-egui/src/panels/monitor_view.rs index 501ead7a5..45809bfbd 100644 --- a/crates/ui-egui/src/panels/monitor_view.rs +++ b/crates/ui-egui/src/panels/monitor_view.rs @@ -363,9 +363,10 @@ pub fn zoom_pan_input(app: &mut FilmcraftApp, ui: &mut egui::Ui, w: Which, area: if ui.rect_contains_pointer(area) && let Some(p) = ctx.pointer_hover_pos() { - // as fast as the Timeline's own wheel zoom + // about 10% per wheel notch (40 points), exponential in the wheel travel so a notch out + // undoes a notch in exactly 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) }; + let f = if (wheel.pinch - 1.0).abs() > 1e-4 { wheel.pinch } else { (wheel.delta.y * 1.1_f32.ln() / 40.0).exp().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); diff --git a/crates/ui-egui/tests/view_ui.rs b/crates/ui-egui/tests/view_ui.rs index 92b34a1fc..30ce1c82c 100644 --- a/crates/ui-egui/tests/view_ui.rs +++ b/crates/ui-egui/tests/view_ui.rs @@ -197,13 +197,21 @@ fn wheel_zooms_about_the_pointer_and_the_middle_button_pans() { 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})); + // one wheel notch (40 points) up: 10% larger; the point under the pointer stays under it + d.ok("ui.scroll", json!({"x": at.0, "y": at.1, "dx": 0.0, "dy": 40.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["zoom"]) - 1.1).abs() < 1e-4, "{p}"); + let want = ((centre.0 - at.0) * 0.1, (centre.1 - at.1) * 0.1); assert!((num(&p["pan"][0]) - want.0).abs() < 1.0 && (num(&p["pan"][1]) - want.1).abs() < 1.0, "pan {} for {want:?}", p["pan"]); + // a notch down, then up again, comes back to the same zoom + d.ok("ui.scroll", json!({"x": at.0, "y": at.1, "dx": 0.0, "dy": -40.0})); + d.frames(2); + assert!((num(&d.program()["zoom"]) - 1.0).abs() < 1e-4, "{}", d.program()); + d.ok("ui.scroll", json!({"x": at.0, "y": at.1, "dx": 0.0, "dy": 40.0})); + d.frames(2); + let p = d.program(); + assert!((num(&p["zoom"]) - 1.1).abs() < 1e-4, "{p}"); // the middle button drags the picture, whatever the tool let (zoom, pan) = (num(&p["zoom"]), [num(&p["pan"][0]), num(&p["pan"][1])]); @@ -225,7 +233,7 @@ fn wheel_zooms_about_the_pointer_and_the_middle_button_pans() { // 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.ok("ui.scroll", json!({"x": centre.0, "y": centre.1, "dx": 0.0, "dy": -200.0})); } d.frames(2); assert!((num(&d.program()["zoom"]) - 0.1).abs() < 1e-6, "stops at 10%"); From ce638039343244796949cbf138687ed4dcc3ded9 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 10:18:44 +0200 Subject: [PATCH 203/260] #980: VP9 decode loop-filters a frame as a superblock-row wavefront Problem: the post stage of a VP9 frame (band assembly, loop filter, publication) ran on one thread. Later frames wait per band for their references, so the serial loop filter (~30 % of the profile) set the pace of every inter frame; 16 threads were only ~1.8x one thread. Fix: rows of a frame's loop filter run as a wavefront, row r two superblocks behind row r - 1 (what the raster order of 8.8.1 allows), taken by the post thread and helper jobs on the decoder's pool. No lock is held while a superblock is filtered: each row filters its own band and rows exchange copies of the 8 rows the filter reaches across a band boundary. Output stays bit-identical; one thread / wasm run the rows in order as before. A row is only taken by a running thread, so waits always make progress, and helpers run under catch_unwind (a failure publishes placeholder bands as before). New conformance fixture narrow_tall (2 superblock columns) and a repeated multi-thread 1080p decode test. 1.2-1.5x faster with 16 threads on a shared machine. Co-Authored-By: Claude Opus 5.5 --- crates/vp9/src/decoder.rs | 370 +++++++++++++++++++++++++++----- crates/vp9/tests/common/mod.rs | 2 + crates/vp9/tests/conformance.rs | 14 ++ docs/gaps.md | 4 +- docs/performance.md | 30 +++ 5 files changed, 361 insertions(+), 59 deletions(-) diff --git a/crates/vp9/src/decoder.rs b/crates/vp9/src/decoder.rs index e385d3f56..af1f969c7 100644 --- a/crates/vp9/src/decoder.rs +++ b/crates/vp9/src/decoder.rs @@ -12,7 +12,8 @@ use crate::tables::*; use crate::tile::{FrameShared, RefUse, Strip, TileDecoder}; use crate::{ColorInfo, Picture, Plane}; use std::collections::VecDeque; -use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Condvar, Mutex, MutexGuard, RwLock}; /// Counters describing what the decoder has seen (useful to check test coverage). #[derive(Clone, Debug, Default, PartialEq, Eq)] @@ -71,8 +72,9 @@ pub struct Decoder { pending: VecDeque, #[cfg(all(feature = "threads", not(target_arch = "wasm32")))] jobs: VecDeque>, + /// Tile columns and the loop filter wavefront of post jobs run on it. #[cfg(feature = "threads")] - pool: Option, + pool: Option>, } impl Default for Decoder { @@ -109,7 +111,7 @@ impl Decoder { pub fn with_threads(threads: usize) -> Self { #[cfg(feature = "threads")] let pool = if threads > 1 && cfg!(not(target_arch = "wasm32")) { - rayon::ThreadPoolBuilder::new().num_threads(threads).thread_name(|i| format!("vp9-{i}")).build().ok() + rayon::ThreadPoolBuilder::new().num_threads(threads).thread_name(|i| format!("vp9-{i}")).build().ok().map(Arc::new) } else { None }; @@ -414,7 +416,16 @@ impl Decoder { self.stats.draft_frames += draft as u64; let lf = (self.st.lf.level > 0 && !draft) .then(|| LfFrame::new(&self.st.lf, &seg, mi_rows, mi_cols, h.color.subsampling_x, h.color.subsampling_y, h.color.bit_depth)); - let job = PostJob { frame: frame.clone(), strips, mi: mi.clone(), lf, out: h.show_frame.then_some((pts, draft)), pools: self.bufs.clone() }; + let job = PostJob { + frame: frame.clone(), + strips, + mi: mi.clone(), + lf, + out: h.show_frame.then_some((pts, draft)), + pools: self.bufs.clone(), + #[cfg(feature = "threads")] + pool: self.pool.clone(), + }; self.run_post(job); // refresh_probs (6.1.2). if counting { @@ -558,9 +569,19 @@ fn recycle_strips(strips: Vec, pools: &Pools) { } } -/// The post stage of one frame: build each band from the tile column strips, loop filter it -/// (superblocks in raster order, so the result is the specification's), publish it once the -/// next band's filtering can no longer change it, and copy it into the output picture. +/// The post stage of one frame: build each band from the tile column strips, loop filter it, +/// publish it once no later filtering can change it, and copy the frame into the output picture. +/// +/// The specification filters superblock by superblock in raster order (8.8.1). Superblock (r, c) +/// changes samples of (r, c - 1) (the vertical edges on its left) and of (r - 1, c) (the +/// horizontal edges on its top reach 7 rows up), and the last superblock before it to change the +/// bottom rows of (r - 1, c) is (r - 1, c + 1). So row r may filter superblock c once row r - 1 +/// has filtered superblock c + 1: rows run as a wavefront, each two superblocks behind the row +/// above, with the specification's result. Every row filters its own band: before superblock +/// c it copies the bottom rows of band r - 1 in that superblock column (shared by row r - 1 once +/// final for it) into its scratch rows, and once done it writes them back into band r - 1, which +/// is then final and published. The post thread takes rows in order and helpers on the +/// decoder's pool join in; with one thread the rows run one after the other, as before. struct PostJob { frame: Arc, strips: Vec, @@ -569,9 +590,11 @@ struct PostJob { /// (pts, draft) when the frame is shown. out: Option<(i64, bool)>, pools: Arc, + #[cfg(feature = "threads")] + pool: Option>, } -/// Publishes placeholder bands if a post job dies, so that waiting frames cannot hang. +/// Publishes placeholder bands if a post job dies or fails, so that waiting frames cannot hang. struct PublishGuard<'a>(&'a Frame); impl Drop for PublishGuard<'_> { @@ -589,64 +612,295 @@ impl PostJob { let f = &*self.frame; let _guard = PublishGuard(f); let mut pic = self.out.map(|(pts, draft)| PictureBuilder::new(f, pts, draft)); - let sb_rows = f.sb_rows(); - let mut held: Option = None; - for r in 0..sb_rows { - let mut band = f.new_band(); + let Some(lf) = self.lf else { + // Without the loop filter every band is final as built. + for r in 0..f.sb_rows() { + let band = build_band(f, &self.strips, r); + if let Some(pic) = &mut pic { + pic.add(f, r, &band); + } + f.publish(r, band); + } + recycle_strip_planes(self.strips, &self.pools); + return pic.map(PictureBuilder::finish); + }; + let wave = Arc::new(Wave::new(self.frame.clone(), self.strips, self.mi, lf, self.pools, pic)); + #[cfg(feature = "threads")] + if let Some(pool) = &self.pool { + // Rows that can be filtered at the same time, the post thread being one of them. + let rows = wave.sb_rows.min(wave.sb_cols.div_ceil(2)).min(pool.current_num_threads() + 1); + for _ in 1..rows { + let w = wave.clone(); + pool.spawn(move || w.work()); + } + } + wave.work(); + if !wave.wait_done() { + return None; + } + // Every row is done, so no helper reads the strips any more. + let strips = std::mem::take(&mut *wave.strips.write().unwrap_or_else(std::sync::PoisonError::into_inner)); + recycle_strip_planes(strips, &wave.pools); + lock(&wave.pic).take().map(PictureBuilder::finish) + } +} + +fn recycle_strip_planes(strips: Vec, pools: &Pools) { + for s in strips { + for p in s.planes { + pools.samples.put(p); + } + } +} + +/// Band `r` of a frame from the tile column strips, before the loop filter. +fn build_band(f: &Frame, strips: &[Strip], r: usize) -> Band { + let mut band = f.new_band(); + for p in 0..3 { + let (fs, bh) = (f.strides[p], f.band_h[p]); + let dst = &mut band.planes[p]; + for s in strips { + let ss = s.strides[p]; + let x0 = s.x_off[p]; + let w = ss.min(fs.saturating_sub(x0)); + for k in 0..bh { + let y = r * bh + k; + let d = (BAND_PAD + k) * fs + x0; + if let (Some(d), Some(s)) = (dst.get_mut(d..d + w), s.planes[p].get(y * ss..y * ss + w)) { + d.copy_from_slice(s); + } + } + } + } + band +} + +/// Progress of one superblock row of the wavefront. +struct RowProgress { + /// Superblocks filtered; `sb_cols` once the row is done (and band r - 1 published). + done: AtomicUsize, + /// Threads waiting on `cv` (so that progress without a waiter costs no wake-up call). + waiters: AtomicUsize, + lock: Mutex<()>, + cv: Condvar, + /// The bottom `BAND_PAD` rows of the band of each plane, shared superblock column by column + /// once final for this row, for the row below. + bottom: Mutex<[Vec; 3]>, + /// The band once the row is done, until the row below has written its scratch rows back. + band: Mutex>, +} + +/// The shared state of a frame's loop filter wavefront (see [`PostJob`]). No lock is held while +/// a superblock is filtered: each row filters its own band and shares only copies. +struct Wave { + frame: Arc, + strips: RwLock>, + mi: Arc, + lf: LfFrame, + pools: Arc, + sb_rows: usize, + sb_cols: usize, + rows: Vec, + /// The next row to take. + next: AtomicUsize, + failed: AtomicBool, + /// The output picture (shown frames), filled band by band as bands are published. + pic: Mutex>, +} + +fn lock(m: &Mutex) -> MutexGuard<'_, T> { + m.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} + +impl Wave { + fn new(frame: Arc, strips: Vec, mi: Arc, lf: LfFrame, pools: Arc, pic: Option) -> Wave { + let sb_rows = frame.sb_rows(); + let sb_cols = lf.mi_cols.div_ceil(8); + let rows = (0..sb_rows) + .map(|_| RowProgress { + done: AtomicUsize::new(0), + waiters: AtomicUsize::new(0), + lock: Mutex::new(()), + cv: Condvar::new(), + bottom: Mutex::new(std::array::from_fn(|p| pools.bands.take_any(BAND_PAD * frame.strides[p], 0))), + band: Mutex::new(None), + }) + .collect(); + Wave { + frame, + strips: RwLock::new(strips), + mi, + lf, + pools, + sb_rows, + sb_cols, + rows, + next: AtomicUsize::new(0), + failed: AtomicBool::new(false), + pic: Mutex::new(pic), + } + } + + /// Take rows and filter them until none is left. A row is only taken by a running thread, so + /// a row waiting for the one above always waits for a thread that is making progress. + fn work(&self) { + while !self.failed.load(Ordering::Acquire) { + let r = self.next.fetch_add(1, Ordering::Relaxed); + if r >= self.sb_rows { + return; + } + let ok = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.row(r))).unwrap_or(false); + if !ok { + self.fail(); + return; + } + } + } + + /// Wait until every row is done (false if one failed). + fn wait_done(&self) -> bool { + self.sb_rows.checked_sub(1).is_none_or(|last| self.wait(last, self.sb_cols)) + } + + /// Wait until row `r` has filtered `need` superblocks (false if a row failed). + fn wait(&self, r: usize, need: usize) -> bool { + let Some(row) = self.rows.get(r) else { return false }; + if row.done.load(Ordering::SeqCst) >= need { + return true; + } + let mut g = lock(&row.lock); + row.waiters.fetch_add(1, Ordering::SeqCst); + let ok = loop { + if row.done.load(Ordering::SeqCst) >= need { + break true; + } + if self.failed.load(Ordering::SeqCst) { + break false; + } + g = row.cv.wait(g).unwrap_or_else(std::sync::PoisonError::into_inner); + }; + row.waiters.fetch_sub(1, Ordering::SeqCst); + ok + } + + fn set_done(&self, r: usize, n: usize) { + if let Some(row) = self.rows.get(r) { + // A waiter registers under the lock before it checks `done`, so either it sees the + // new value or it is counted here and woken. + row.done.store(n, Ordering::SeqCst); + if row.waiters.load(Ordering::SeqCst) > 0 { + let _g = lock(&row.lock); + row.cv.notify_all(); + } + } + } + + /// Stop the wavefront: waiting rows give up, the post job publishes placeholders. + fn fail(&self) { + self.failed.store(true, Ordering::SeqCst); + for row in &self.rows { + let _g = lock(&row.lock); + row.cv.notify_all(); + } + } + + /// Publish final band `r` and copy it into the output picture. + fn publish(&self, r: usize, band: Band) { + if let Some(pic) = lock(&self.pic).as_mut() { + pic.add(&self.frame, r, &band); + } + self.frame.publish(r, band); + } + + /// Share the bottom rows of superblock column `c` of row `r`'s band (final for this row). + fn share_bottom(&self, r: usize, band: &Band, c: usize) { + let Some(row) = self.rows.get(r) else { return }; + let mut bottom = lock(&row.bottom); + for (p, dst) in bottom.iter_mut().enumerate() { + copy_column(&self.frame, p, c, self.sb_cols, &band.planes[p], self.frame.band_h[p], dst, 0); + } + } + + /// Build and filter row `r`; publish band r - 1 (and the last band) when done. + fn row(&self, r: usize) -> bool { + let f = &*self.frame; + let sb_cols = self.sb_cols; + let mut band = build_band(f, &self.strips.read().unwrap_or_else(std::sync::PoisonError::into_inner), r); + let above = r.checked_sub(1).and_then(|a| self.rows.get(a)); + for c in 0..sb_cols { + if let Some(above) = above { + // Superblock c + 1 of the row above is filtered: the bottom rows of its column c + // are final for that row and go into our scratch rows. + if !self.wait(r - 1, (c + 2).min(sb_cols)) { + return false; + } + let bottom = lock(&above.bottom); + for (p, src) in bottom.iter().enumerate() { + copy_column(f, p, c, sb_cols, src, 0, &mut band.planes[p], 0); + } + } + let [a, b, d] = &mut band.planes; + let mut views = [band_view(f, a, 0, r), band_view(f, b, 1, r), band_view(f, d, 2, r)]; + filter_superblock(&mut views, &self.mi, &self.lf, r * 8, c * 8); + // Superblock c filtered the left edge of column c, so column c - 1 is final here. + if c > 0 { + self.share_bottom(r, &band, c - 1); + } + if c + 1 < sb_cols { + self.set_done(r, c + 1); + } + } + self.share_bottom(r, &band, sb_cols.saturating_sub(1)); + if let Some(above) = above { + // The scratch rows now hold the final bottom rows of band r - 1. + let Some(mut pb) = lock(&above.band).take() else { return false }; for p in 0..3 { let (fs, bh) = (f.strides[p], f.band_h[p]); - let dst = &mut band.planes[p]; - for s in &self.strips { - let ss = s.strides[p]; - let x0 = s.x_off[p]; - let w = ss.min(fs - x0); - for k in 0..bh { - let y = r * bh + k; - let d = (BAND_PAD + k) * fs + x0; - dst[d..d + w].copy_from_slice(&s.planes[p][y * ss..y * ss + w]); - } + if let (Some(d), Some(s)) = (pb.planes[p].get_mut(bh * fs..(bh + BAND_PAD) * fs), band.planes[p].get(..BAND_PAD * fs)) { + d.copy_from_slice(s); } } - if let Some(lf) = &self.lf { - if let Some(prev) = &held { - // The bottom rows of the previous band, which this row's filter may change. - for p in 0..3 { - let (fs, bh) = (f.strides[p], f.band_h[p]); - band.planes[p][..BAND_PAD * fs].copy_from_slice(&prev.planes[p][bh * fs..(bh + BAND_PAD) * fs]); - } - } - let [a, b, c] = &mut band.planes; - let mut views = [band_view(f, a, 0, r), band_view(f, b, 1, r), band_view(f, c, 2, r)]; - for c in 0..lf.mi_cols.div_ceil(8) { - filter_superblock(&mut views, &self.mi, lf, r * 8, c * 8); - } - if let Some(prev) = &mut held { - for p in 0..3 { - let (fs, bh) = (f.strides[p], f.band_h[p]); - prev.planes[p][bh * fs..(bh + BAND_PAD) * fs].copy_from_slice(&band.planes[p][..BAND_PAD * fs]); - } - } - } - if let Some(prev) = held.take() { - if let Some(pic) = &mut pic { - pic.add(f, r - 1, &prev); - } - f.publish(r - 1, prev); - } - held = Some(band); + self.publish(r - 1, pb); } - if let Some(last) = held { - if let Some(pic) = &mut pic { - pic.add(f, sb_rows - 1, &last); - } - f.publish(sb_rows - 1, last); + if r + 1 == self.sb_rows { + self.publish(r, band); + } else if let Some(row) = self.rows.get(r) { + *lock(&row.band) = Some(band); } - for s in self.strips { - for p in s.planes { - self.pools.samples.put(p); + self.set_done(r, sb_cols); + true + } +} + +impl Drop for Wave { + fn drop(&mut self) { + recycle_strip_planes(std::mem::take(self.strips.get_mut().unwrap_or_else(std::sync::PoisonError::into_inner)), &self.pools); + for row in &self.rows { + for p in std::mem::take(&mut *lock(&row.bottom)) { + self.pools.bands.put(p); + } + if let Some(b) = lock(&row.band).take() { + for p in b.planes { + self.pools.bands.put(p); + } } } - pic.map(PictureBuilder::finish) + } +} + +/// Copy `BAND_PAD` rows of superblock column `c` of plane `p` (the last column takes the rest of +/// the stride) from row `src_row` of `src` to row `dst_row` of `dst`, both `strides[p]` wide. +#[allow(clippy::too_many_arguments)] +fn copy_column(f: &Frame, p: usize, c: usize, sb_cols: usize, src: &[u16], src_row: usize, dst: &mut [u16], dst_row: usize) { + let fs = f.strides[p]; + let cw = fs / sb_cols.max(1); + let x0 = c * cw; + let x1 = if c + 1 >= sb_cols { fs } else { x0 + cw }; + for k in 0..BAND_PAD { + let (s, d) = ((src_row + k) * fs, (dst_row + k) * fs); + if let (Some(d), Some(s)) = (dst.get_mut(d + x0..d + x1), src.get(s + x0..s + x1)) { + d.copy_from_slice(s); + } } } diff --git a/crates/vp9/tests/common/mod.rs b/crates/vp9/tests/common/mod.rs index 2619f96dd..9cb243fce 100644 --- a/crates/vp9/tests/common/mod.rs +++ b/crates/vp9/tests/common/mod.rs @@ -91,6 +91,8 @@ pub const FIXTURES: &[Fixture] = &[ fx!("error_resilient", "testsrc2", 352, 288, 16, NOISE, P420, ["-b:v", "600k", "-error-resilient", "1"]), fx!("odd_size", "testsrc2", 347, 251, 10, NOISE, P420, ["-b:v", "600k", "-frame-parallel", "0"]), fx!("tiny_odd", "testsrc2", 33, 17, 8, NOISE, P420, ["-b:v", "200k", "-frame-parallel", "0"]), + // Two superblock columns and twelve rows: the loop filter wavefront waits for whole rows. + fx!("narrow_tall", "testsrc2", 120, 720, 6, NOISE, P420, ["-b:v", "400k", "-frame-parallel", "0"]), fx!("row_mt", "testsrc2", 640, 360, 10, NOISE, P420, ["-b:v", "1M", "-row-mt", "1", "-tile-columns", "1", "-frame-parallel", "0"]), fx!("speed0", "mandelbrot", 176, 144, 8, NOISE, P420, ["2pass", "-b:v", "300k", "-speed", "0", "-frame-parallel", "0"]), fx!("speed1", "testsrc2", 352, 288, 10, NOISE, P420, ["-b:v", "600k", "-speed", "1", "-frame-parallel", "0"]), diff --git a/crates/vp9/tests/conformance.rs b/crates/vp9/tests/conformance.rs index 70e0bb1c6..ceb875e0b 100644 --- a/crates/vp9/tests/conformance.rs +++ b/crates/vp9/tests/conformance.rs @@ -40,6 +40,7 @@ fixture_tests!( error_resilient, odd_size, tiny_odd, + narrow_tall, row_mt, speed0, speed1, @@ -101,6 +102,19 @@ fn draft_mode_changes_only_flagged_non_reference_frames() { } } +/// The loop filter runs as a wavefront over superblock rows on the decoder's pool: repeated +/// decodes with several thread counts all match ffmpeg (a race shows up as a mismatch). +#[test] +fn loop_filter_wavefront_repeatable() { + let f = common::fixture("hd_1080p"); + let Some((ivf, yuv)) = common::ensure(f) else { return }; + let reference = std::fs::read(yuv).unwrap(); + for threads in [2, 5, 16, 16, 16] { + let pics = common::decode_file_threads(&ivf, threads).map_err(|e| e.1).unwrap(); + common::compare(&pics, &reference, f.pix_fmt).unwrap_or_else(|e| panic!("threads {threads}: {e}")); + } +} + /// Two streams of different sizes concatenated into one IVF (key frame at the size change). #[test] fn resize_concatenated() { diff --git a/docs/gaps.md b/docs/gaps.md index 564541067..501a7a636 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -222,7 +222,9 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** 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. + (#424); Render Audio still renders whole audio segments. VP9 software decode loop-filters a + frame as a wavefront over superblock rows on the decoder's pool instead of on one thread, + bit-identical, 1.2–1.5× faster with 16 threads (#980). - **Estimate:** 30–60 h. Doc: [performance.md](performance.md). ## G17. Audio device and playback reliability diff --git a/docs/performance.md b/docs/performance.md index 96cb5f625..c3891fd74 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -518,6 +518,36 @@ decode, and the UI-thread present (upload and draw, including the backdrop copie blended layers) stays at 9–10 ms p50, as for `stack3`. Before, every frame was composited on the CPU (≈ 2 cores more at 23.976 fps) and Full resolution could not keep up at this load. +## Results (VP9 loop filter wavefront, #980, before → after) + +The VP9 post stage of a frame (band assembly, loop filter, publication) ran on one thread, and +later frames wait per band for their references, so the loop filter (about 30 % of the decode +profile) set the pace of every inter frame: with 16 threads the tile columns spent most of their +time waiting for the previous frame's bands. The loop filter now runs as a wavefront over +superblock rows (row r two superblocks behind row r - 1, which is what the raster order of 8.8.1 +allows), the post thread and helpers on the decoder's pool each taking a row. No lock is held +while a superblock is filtered: rows exchange copies of the 8 rows the filter reaches across a +band boundary. Output is bit-identical (the conformance tests compare 1, 2, 3, 5 and 16 threads +with ffmpeg). + +AMD Ryzen 7 7800X3D (8 cores / 16 threads), shared with other agents' builds, 2026-10-11. +`vp9dec` example, 16 threads, runs alternating base / after; paired speed-up = base wall time / +after wall time per pair: + +| fixture | load | paired speed-up, median (range) | after faster | CPU ms/frame | +|---|---|---|---|---| +| `bench_1080p` (4 tile columns) | 15–18 | 1.39 (1.20–2.09) | 6/6 | 13.5 → 15.3 | +| `bench_4k` (8 tile columns) | 15–18 | 1.52 (1.49–1.59) | 6/6 | 48.0 → 57.8 | +| 1080p30 realtime, noisy (6 Mb/s) | 15–18 | 1.56 (1.27–1.72) | 6/6 | 15.3 → 20.8 | +| `bench_1080p` | 25–33 | 1.17 (0.91–1.65) | 4/6 | 15.7 → 17.9 | +| `bench_4k` | 25–33 | 1.29 (0.99–2.03) | 5/6 | 56.9 → 63.1 | +| 1080p30 realtime, noisy | 25–33 | 1.53 (1.40–1.94) | 6/6 | 22.5 → 25.4 | + +With one thread the rows run one after the other: 31.12 → 31.28 G user instructions for the noisy +1080p clip (+0.5 %, `perf stat`). The extra CPU with threads is mostly rayon workers spinning +before they sleep. With the machine oversubscribed (load above 16) the wavefront gains less, as +every row waits for the one above. + ## Results (M4.10: 4K VP9, AV1 and HEVC, before → after) Baseline = commit `a814ff6` (decoders unchanged since M4.8); after = M4.10. Interleaved runs on From 76e7bfb8c919ca911d7c5de60ad31e8e64e9a762 Mon Sep 17 00:00:00 2001 From: ReReddo <249398616+ReReddo@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:19:29 +0300 Subject: [PATCH 204/260] dock: switch tabs with the mouse wheel over a tab strip Scrolling over a panel group's tab strip makes the next tab active (wheel down) or the previous one (wheel up), one tab per wheel notch. Trackpad scrolling adds up until it reaches a notch, so a small swipe moves one tab rather than several. It stops at the first and last tab and does nothing over the panel's content. Works for docked and floating groups, and steps through the Timeline's sequence tabs too. --- crates/ui-egui/src/dock.rs | 16 +++++++++ crates/ui-egui/tests/panels_ui.rs | 57 +++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 23b4b6cc1..bd6e73a2f 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -687,6 +687,22 @@ pub fn draw_group_chrome_details( list_opened = !open; } } + // The wheel over the strip steps through the tabs: down = next, up = previous, one tab + // per wheel notch (trackpad scrolling adds up to a notch first), stopping at the ends. + let wheel_id = egui::Id::new(("tab-wheel", g.path.clone())); + if ui.rect_contains_pointer(strip) && !tabs.is_empty() { + const NOTCH: f32 = 40.0; + let total = ui.ctx().data(|d| d.get_temp::(wheel_id)).unwrap_or(0.0) + crate::panels::timeline::wheel_input(ui.ctx()).delta.y; + let steps = (total / NOTCH).trunc(); + ui.ctx().data_mut(|d| d.insert_temp(wheel_id, total - steps * NOTCH)); + let to = (active_at as i64).saturating_sub(steps as i64).clamp(0, tabs.len() as i64 - 1) as usize; + if let Some(tab) = tabs.get(to).filter(|_| to != active_at) { + activate(tab, &mut actions); + actions.push(DockAction::Focus(tab.panel)); + } + } else { + ui.ctx().data_mut(|d| d.remove::(wheel_id)); + } let mut x = strip.min.x + 12.0; // sequence tabs as drawn (for dragging one past its neighbours), and the one being dragged let mut seq_rects: Vec<(u64, Rect)> = Vec::new(); diff --git a/crates/ui-egui/tests/panels_ui.rs b/crates/ui-egui/tests/panels_ui.rs index 8f1b10136..ed2cf4584 100644 --- a/crates/ui-egui/tests/panels_ui.rs +++ b/crates/ui-egui/tests/panels_ui.rs @@ -209,6 +209,63 @@ fn panel_menu_maximize_restores_and_an_empty_dock_reopens() { assert!(d.has("panel.tab.Program") && d.has("panel.tab.Timeline")); } +/// The wheel over a tab strip switches that group's tab: down = next, up = previous, one tab per +/// notch, smaller trackpad steps adding up to a notch, stopping at the ends. Over the panel's +/// content the wheel leaves the tabs alone. +#[test] +fn wheel_over_a_tab_strip_switches_tabs() { + use filmcraft_ui_egui::dock::{DockNode, PanelKind}; + fn shown(d: &mut Driver) -> PanelKind { + fn find(n: &DockNode) -> Option { + match n { + DockNode::Tabs { panels, active } if panels.contains(&PanelKind::Project) => panels.get(*active).copied(), + DockNode::Tabs { .. } => None, + DockNode::Split { a, b, .. } => find(a).or_else(|| find(b)), + } + } + find(&d.app().ui.dock).unwrap() + } + fn wheel(d: &mut Driver, dy: f64) { + let id = format!("panel.tab.{}", shown(d).id()); + d.ok("ui.scroll", json!({"id": id, "dy": dy})); + d.frames(2); + } + let mut d = Driver::demo(); + assert_eq!(shown(&mut d), PanelKind::Project); + wheel(&mut d, -40.0); + assert_eq!(shown(&mut d), PanelKind::MediaBrowser); + wheel(&mut d, 40.0); + assert_eq!(shown(&mut d), PanelKind::Project); + wheel(&mut d, 120.0); + assert_eq!(shown(&mut d), PanelKind::Project, "no wrap past the first tab"); + + // a trackpad swipe in small steps moves one tab once the steps add up to a notch + let r = d.ok("ui.elements", json!({"prefix": "panel.tab.Project"}))[0]["rect"].clone(); + let at = egui::pos2((r[0].as_f64().unwrap() + r[2].as_f64().unwrap() / 2.0) as f32, (r[1].as_f64().unwrap() + r[3].as_f64().unwrap() / 2.0) as f32); + d.harness.input_mut().events.push(egui::Event::PointerMoved(at)); + d.frames(1); + for step in 0..5 { + d.harness.input_mut().events.push(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -10.0), + modifiers: egui::Modifiers::NONE, + phase: egui::TouchPhase::Move, + }); + d.frames(1); + let expect = if step < 3 { PanelKind::Project } else { PanelKind::MediaBrowser }; + assert_eq!(shown(&mut d), expect, "after {} pt", (step + 1) * 10); + } + + d.ok("ui.scroll", json!({"id": "panel.MediaBrowser", "dy": -40.0})); + d.frames(2); + assert_eq!(shown(&mut d), PanelKind::MediaBrowser, "the wheel over the content keeps the tab"); + + for _ in 0..10 { + wheel(&mut d, -40.0); + } + assert_eq!(shown(&mut d), PanelKind::History, "no wrap past the last tab"); +} + #[test] fn lumetri_scopes_layouts_menu_and_footer() { let mut d = Driver::demo(); From c76d8211d12ad119b2ea3dac1fb146b626b6fce3 Mon Sep 17 00:00:00 2001 From: ReReddo <249398616+ReReddo@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:22:23 +0300 Subject: [PATCH 205/260] timeline: drag the outer track edges to change track heights The top edge of the highest video track now resizes the video tracks, like the lines between headers do (same cursor, drag up to grow). The bottom edge of the last audio track can now also be grabbed from just below the line, in the empty space under the tracks, so both outer edges work the same way. Each edge has an automation id (timeline.track..resizeTop, timeline.track..resize). --- crates/ui-egui/src/panels/timeline.rs | 17 ++- .../ui-egui/tests/timeline_track_height_ui.rs | 114 ++++++++++++++++++ 2 files changed, 127 insertions(+), 4 deletions(-) create mode 100644 crates/ui-egui/tests/timeline_track_height_ui.rs diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index d5766c0bb..0910e25ab 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1197,10 +1197,19 @@ fn draw_headers(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, rows: if hrect.height() >= 40.0 { p.text(pos2(hrect.min.x + 94.0, hrect.min.y + 34.0), Align2::LEFT_CENTER, &tr.name, Tokens::ui(11.0), t.text_dim); } - // resize track height by dragging the header's bottom edge - let edge = Rect::from_min_max(pos2(hrect.min.x, hrect.max.y - 3.0), pos2(hrect.max.x, hrect.max.y + 2.0)).intersect(visible); - if edge.height() > 0.0 { - let eresp = ui.interact(edge, egui::Id::new(("trackh", r.track.0)), Sense::drag()); + // resize track height by dragging the header's bottom edge, or the outer edge of the top video + // track or the last audio track, which also reaches into the empty space past it + let header_column = Rect::from_min_max(pos2(hrect.min.x, clip_rect.min.y), pos2(hrect.max.x, clip_rect.max.y)); + let last_audio = r.kind == TrackKind::Audio && r.index + 1 == seq.audio_tracks.len(); + let bottom = Rect::from_min_max(pos2(hrect.min.x, hrect.max.y - 3.0), pos2(hrect.max.x, hrect.max.y + 2.0)); + let top = (r.kind == TrackKind::Video && r.index + 1 == seq.video_tracks.len()) + .then(|| ("resizeTop", Rect::from_min_max(pos2(hrect.min.x, hrect.min.y - 2.0), pos2(hrect.max.x, hrect.min.y + 3.0)).intersect(header_column))); + for (name, edge) in std::iter::once(("resize", bottom.intersect(if last_audio { header_column } else { visible }))).chain(top) { + if edge.height() <= 0.0 { + continue; + } + app.auto.add(&format!("timeline.track.{label}.{name}"), edge, "Drag to change the track height"); + let eresp = ui.interact(edge, egui::Id::new(("trackh", name, r.track.0)), Sense::drag()); if eresp.hovered() || eresp.dragged() { ui.ctx().set_cursor_icon(CursorIcon::ResizeVertical); } diff --git a/crates/ui-egui/tests/timeline_track_height_ui.rs b/crates/ui-egui/tests/timeline_track_height_ui.rs new file mode 100644 index 000000000..eb272be4b --- /dev/null +++ b/crates/ui-egui/tests/timeline_track_height_ui.rs @@ -0,0 +1,114 @@ +//! Headless UI tests of changing track heights by dragging the lines between track headers, +//! including the outer edges: the top of the highest video track and the bottom of the last audio +//! track. + +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, 1400.0)).with_max_steps(10_000).build_eframe(move |_cc| app); + let mut d = Driver { harness, tx }; + d.frames(4); + // short enough that every track is in view, with empty space above and below them + d.ok("ui.set", json!({"timeline": {"videoTrackHeight": 24.0, "audioTrackHeight": 24.0}})); + d.frames(3); + 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(&self) -> &FilmcraftApp { + self.harness.state() + } + + fn heights(&self) -> (f32, f32) { + let v = &self.app().ui.timeline; + (v.video_track_h, v.audio_track_h) + } + + /// Drag from the middle of the element `id` by `dy` points. + fn drag(&mut self, id: &str, fy: f32, dy: f32) { + let v = self.ok("ui.elements", json!({"prefix": id})); + let e = v.as_array().unwrap().iter().find(|e| e["id"] == json!(id)).unwrap_or_else(|| panic!("no element {id}")).clone(); + let r: Vec = e["rect"].as_array().unwrap().iter().map(|n| n.as_f64().unwrap() as f32).collect(); + let (x, y) = (r[0] + r[2] / 2.0, r[1] + r[3] * fy); + self.ok("ui.drag", json!({"from": {"x": x, "y": y}, "to": {"x": x, "y": y + dy}})); + self.frames(3); + } + + fn labels(&self) -> (String, String) { + let seq = self.app().session.active_sequence().unwrap(); + (format!("V{}", seq.video_tracks.len()), format!("A{}", seq.audio_tracks.len())) + } +} + +#[test] +fn the_top_edge_of_the_highest_video_track_resizes_the_video_tracks() { + let mut d = Driver::demo(); + let (top, _) = d.labels(); + let (v0, a0) = d.heights(); + // dragging the outer edge up makes the video tracks taller, as the line below it does + d.drag(&format!("timeline.track.{top}.resizeTop"), 0.5, -15.0); + let (v1, a1) = d.heights(); + assert!(v1 > v0 + 5.0, "video tracks grow: {v0} -> {v1}"); + assert_eq!(a1, a0, "audio tracks are left alone"); + // and down makes them shorter again + d.drag(&format!("timeline.track.{top}.resizeTop"), 0.5, 15.0); + assert!(d.heights().0 < v1 - 5.0); + // the line below it still works the same way + let v2 = d.heights().0; + d.drag(&format!("timeline.track.{top}.resize"), 0.5, -15.0); + assert!(d.heights().0 > v2 + 5.0); +} + +#[test] +fn the_bottom_edge_of_the_last_audio_track_resizes_the_audio_tracks_from_either_side_of_the_line() { + let mut d = Driver::demo(); + let (_, last) = d.labels(); + let id = format!("timeline.track.{last}.resize"); + let (v0, a0) = d.heights(); + // from just past the line, in the empty space below the last track + d.drag(&id, 0.9, 15.0); + let (v1, a1) = d.heights(); + assert!(a1 > a0 + 5.0, "audio tracks grow: {a0} -> {a1}"); + assert_eq!(v1, v0, "video tracks are left alone"); + // and from just inside it + d.drag(&id, 0.1, -15.0); + assert!(d.heights().1 < a1 - 5.0); +} From 9f4814335be47207a2e350c6a0c69bcaae2432eb Mon Sep 17 00:00:00 2001 From: ReReddo <249398616+ReReddo@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:33:58 +0300 Subject: [PATCH 206/260] timeline: update box selection live while it is dragged The clips a box touches are selected as it is dragged, and a clip that leaves the box as it shrinks is deselected again, instead of the selection only changing on release. Shift or Ctrl, held when the drag begins, add to the selection the drag started with, as in the Project panel. Escape cancels the box and puts that selection back. The release still runs a single timeline.select, so the drag is one selection change. --- crates/ui-egui/src/panels/timeline.rs | 54 ++++--- crates/ui-egui/tests/timeline_marquee_ui.rs | 159 ++++++++++++++++++++ 2 files changed, 196 insertions(+), 17 deletions(-) create mode 100644 crates/ui-egui/tests/timeline_marquee_ui.rs diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index d5766c0bb..0d1c51107 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -151,8 +151,12 @@ pub enum Drag { Pan { last: Pos2, }, + /// Box selection, shown live: `before` is the selection when the drag began, which Shift or + /// Ctrl (`add`) keep; the release commits one `timeline.select`. Marquee { start: Pos2, + before: Vec, + add: bool, }, Divider, ZoomBar { @@ -1697,6 +1701,18 @@ fn preview_drag(app: &FilmcraftApp, seq: &Sequence, out: &mut HashMap Vec { + let (a, b) = (layout.tick_at(r.min.x), layout.tick_at(r.max.x)); + layout + .rows + .iter() + .filter(|row| row.rect.intersects(r)) + .filter_map(|row| seq.track(row.track)) + .flat_map(|tr| tr.items.iter().filter(|i| i.start < b && i.end() > a).map(|i| i.id)) + .collect() +} + fn shift_track(seq: &Sequence, tid: TrackId, delta: i32) -> Option { if delta == 0 { return Some(tid); @@ -2163,7 +2179,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & // the video and audio tracks, #683) (_, Grab::Other(hit @ (Hit::Empty { .. } | Hit::None))) if layout.content.contains(p) => { if resp.drag_started() { - Some(Drag::Marquee { start: p }) + Some(Drag::Marquee { start: p, before: app.session.state.selection.clone(), add: mods.shift || mods.command }) } else { app.session.state.selection.clear(); app.session.state.edit_points.clear(); @@ -2278,11 +2294,20 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & Some(Drag::Transition { id, edge, from, start, duration }) } Drag::Volume(v) => Some(Drag::Volume(super::timeline_volume::drag(app, ui, seq, layout, v, p.y))), - Drag::Marquee { start } => { + Drag::Marquee { start, before, add } => { let r = Rect::from_two_pos(start, p); ui.painter().rect_filled(r, 0.0, Color32::from_white_alpha(18)); ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, Color32::from_white_alpha(140)), StrokeKind::Inside); - Some(Drag::Marquee { start }) + // the clips in the box are selected as it grows and shrinks; only the release runs + // the command, so the drag is one selection change + let mut sel = if add { before.clone() } else { Vec::new() }; + for c in filmcraft_engine::commands::with_links(&app.session, &marquee_clips(seq, layout, r)) { + if !sel.contains(&c) { + sel.push(c); + } + } + app.session.state.selection = sel; + Some(Drag::Marquee { start, before, add }) } other => Some(other), }; @@ -2329,20 +2354,15 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & let moved = seq.all_tracks().find_map(|tr| tr.transitions.iter().find(|x| x.id == id)).is_some_and(|x| (x.start, x.duration) != (start, duration)); moved.then(|| app.session.execute("sequence.setTransition", json!({"transition": id.0, "start": start.0, "duration": duration.0}))) } - Drag::Marquee { start } => { - if let Some(end) = resp.interact_pointer_pos() { - let r = Rect::from_two_pos(start, end); - let (a, b) = (layout.tick_at(r.min.x), layout.tick_at(r.max.x)); - let ids: Vec = layout - .rows - .iter() - .filter(|row| row.rect.intersects(r)) - .filter_map(|row| seq.track(row.track)) - .flat_map(|tr| tr.items.iter().filter(|i| i.start < b && i.end() > a).map(|i| i.id.0)) - .collect(); - Some(app.session.execute("timeline.select", json!({"clips": ids, "add": mods.shift}))) - } else { - None + Drag::Marquee { start, before, add } => { + app.session.state.selection = before; + // egui ends a drag on Escape: that cancels the box, as in the Project panel + match resp.interact_pointer_pos() { + Some(end) if !ctx.input(|i| i.key_pressed(egui::Key::Escape)) => { + let ids: Vec = marquee_clips(seq, layout, Rect::from_two_pos(start, end)).iter().map(|c| c.0).collect(); + Some(app.session.execute("timeline.select", json!({"clips": ids, "add": add}))) + } + _ => None, } } _ => None, diff --git a/crates/ui-egui/tests/timeline_marquee_ui.rs b/crates/ui-egui/tests/timeline_marquee_ui.rs new file mode 100644 index 000000000..962eefbac --- /dev/null +++ b/crates/ui-egui/tests/timeline_marquee_ui.rs @@ -0,0 +1,159 @@ +//! Headless UI tests of box (marquee) selection in the Timeline: the selection follows the box +//! while it is dragged, Shift adds to the selection the drag began with, and Escape cancels. + +use std::sync::mpsc::{Sender, channel}; + +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_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, + modifiers: egui::Modifiers, +} + +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, modifiers: egui::Modifiers::NONE }; + 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) -> egui::Rect { + 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() as f32).collect(); + egui::Rect::from_min_size(egui::pos2(r[0], r[1]), egui::vec2(r[2], r[3])) + } + + fn send(&mut self, e: egui::Event) { + let modifiers = self.modifiers; + self.harness.input_mut().events.extend([egui::Event::ModifiersChanged(modifiers), e]); + self.frames(1); + } + + fn button(&mut self, pos: egui::Pos2, pressed: bool) { + let modifiers = self.modifiers; + self.send(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers }); + } + + /// Move the pointer to `to` in small steps, a frame each. + fn move_to(&mut self, from: egui::Pos2, to: egui::Pos2) { + for k in 1..=8 { + self.send(egui::Event::PointerMoved(from.lerp(to, k as f32 / 8.0))); + } + } + + fn selected(&self, clip: u64) -> bool { + self.harness.state().session.state.selection.contains(&ClipId(clip)) + } + + /// The last two clips of Video 1, and a point on Video 1 just past the end of the sequence. + fn setup(&mut self) -> (u64, u64, egui::Rect, egui::Rect, egui::Pos2) { + self.ok("ui.set", json!({"focused": "Timeline"})); + let seq = self.exec("sequence.inspect", json!({})); + let items = seq["video"][0]["items"].as_array().unwrap().clone(); + let id = |i: &Value| i["clip"].as_u64().unwrap(); + let (a, b) = (id(&items[items.len() - 2]), id(&items[items.len() - 1])); + let (ra, rb) = (self.rect(&format!("timeline.clip.{a}")), self.rect(&format!("timeline.clip.{b}"))); + let start = egui::pos2(rb.max.x + 12.0, rb.center().y); + assert_eq!(self.ok("ui.timeline.hit", json!({"x": start.x, "y": start.y}))["hit"], "empty", "the drag starts on empty space"); + (a, b, ra, rb, start) + } +} + +#[test] +fn the_selection_follows_the_box_while_it_is_dragged() { + let mut d = Driver::demo(); + let (a, b, ra, rb, start) = d.setup(); + d.exec("timeline.select", json!({"clips": []})); + d.send(egui::Event::PointerMoved(start)); + d.button(start, true); + // into the last clip: selected before the button comes up + let over_b = egui::pos2(rb.center().x, start.y + 2.0); + d.move_to(start, over_b); + assert!(d.selected(b) && !d.selected(a), "the box selects what it touches as it grows"); + // on into the clip before it + let over_a = egui::pos2(ra.center().x, start.y + 2.0); + d.move_to(over_b, over_a); + assert!(d.selected(a) && d.selected(b)); + // and back: the clip that left the box is no longer selected + d.move_to(over_a, over_b); + assert!(d.selected(b) && !d.selected(a), "a clip leaving the box is deselected"); + d.button(over_b, false); + d.frames(2); + assert!(d.selected(b) && !d.selected(a), "the release keeps what the box showed"); +} + +#[test] +fn shift_adds_to_the_selection_the_drag_began_with() { + let mut d = Driver::demo(); + let (a, b, _, rb, start) = d.setup(); + d.exec("timeline.select", json!({"clips": [a]})); + d.modifiers = egui::Modifiers::SHIFT; + d.send(egui::Event::PointerMoved(start)); + d.button(start, true); + let over_b = egui::pos2(rb.center().x, start.y + 2.0); + d.move_to(start, over_b); + assert!(d.selected(a) && d.selected(b), "Shift keeps the earlier selection and adds the box"); + // shrinking the box to nothing leaves the selection the drag began with + d.move_to(over_b, start + egui::vec2(-2.0, 2.0)); + assert!(d.selected(a) && !d.selected(b)); + d.move_to(start + egui::vec2(-2.0, 2.0), over_b); + d.button(over_b, false); + d.modifiers = egui::Modifiers::NONE; + d.frames(2); + assert!(d.selected(a) && d.selected(b)); +} + +#[test] +fn escape_cancels_the_box_and_restores_the_selection() { + let mut d = Driver::demo(); + let (a, b, ra, _, start) = d.setup(); + d.exec("timeline.select", json!({"clips": [a]})); + d.send(egui::Event::PointerMoved(start)); + d.button(start, true); + let over_a = egui::pos2(ra.center().x, start.y + 2.0); + d.move_to(start, over_a); + assert!(d.selected(b)); + d.send(egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }); + d.button(over_a, false); + d.frames(2); + assert!(d.selected(a) && !d.selected(b), "Escape put back the selection the drag began with"); +} From 1d1d51cbbdcc06b7a19bce04b5716fd813107ea8 Mon Sep 17 00:00:00 2001 From: ReReddo <249398616+ReReddo@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:34:02 +0300 Subject: [PATCH 207/260] meters: adapt the layout to narrow and short panels, move while scrubbing The Audio Meters panel now leaves out what doesn't fit instead of drawing text over other text. As it narrows, the loudness readout drops its LUFS/dBTP units, then the readout goes, then the dB scale goes so the bars keep their room. As it gets shorter, the scale numbers thin out to every 6, 12 or 24 dB and then go, and the channel letters and "dB" under the bars go. At the default size it looks as before. The scale numbers are listed under the automation id audioMeters.scale. While the playhead is scrubbed with audio scrubbing on, the meters (and the Audio Track Mixer's) show the grains being heard, as they do in playback. --- crates/ui-egui/src/panels/meters.rs | 266 +++++++++++++++++++++++++--- crates/ui-egui/src/panels/mixer.rs | 3 +- crates/ui-egui/tests/meters_ui.rs | 172 ++++++++++++++++++ 3 files changed, 414 insertions(+), 27 deletions(-) create mode 100644 crates/ui-egui/tests/meters_ui.rs diff --git a/crates/ui-egui/src/panels/meters.rs b/crates/ui-egui/src/panels/meters.rs index 03555f2d4..66ddfeeb8 100644 --- a/crates/ui-egui/src/panels/meters.rs +++ b/crates/ui-egui/src/panels/meters.rs @@ -45,6 +45,96 @@ fn lufs_text(v: f64) -> String { if v.is_finite() && v > -70.0 { format!("{v:.1}") } else { "—".into() } } +const SCALE_FONT: f32 = 8.5; +const READOUT_FONT: f32 = 9.5; + +/// Where the panel's parts go. What doesn't fit is left out rather than drawn over something +/// else: as the panel narrows, the loudness readout drops its units and then goes, and the dB +/// scale goes once the bars would get too thin; as it gets shorter, the scale numbers thin out to +/// every 6, 12 or 24 dB and then go, and the channel names and "dB" under the bars go. +#[derive(Debug)] +struct Parts { + /// The bars. + area: Rect, + /// Scale numbers and where their text goes. + scale: Vec<(i32, Rect)>, + /// "dB" under the scale. + unit: Option, + /// The name under each bar. + names: Vec, + /// The loudness readout, and whether its values keep their units. + readout: Option<(Rect, bool)>, +} + +/// Lay out a meter of `names.len()` bars in `rect`; `size` measures a text in a font. +fn parts(rect: Rect, names: &[&str], size: impl Fn(&str, egui::FontId) -> egui::Vec2) -> Parts { + let n = names.len().max(1) as f32; + let readout = (rect.height() > 260.0) + .then(|| { + // a row: the label at the left and the value at the right, 6 inside a box inset 4 + let room = rect.width() - 20.0; + let label = size("TP", Tokens::ui(READOUT_FONT)).x; + let fits = |value: &str| label + 6.0 + size(value, Tokens::mono(READOUT_FONT)).x <= room; + if fits("-88.8 dBTP") { Some(true) } else { fits("-88.8").then_some(false) } + }) + .flatten(); + let lufs_h = if readout.is_some() { 64.0 } else { 0.0 }; + let feet = rect.height() >= 90.0; + let top = rect.min.y + 8.0; + let bottom = (rect.max.y - lufs_h - if feet { 30.0 } else { 8.0 }).max(top); + let h = bottom - top; + let widest = size("-57", Tokens::ui(SCALE_FONT)); + let column = (widest.x + 10.0).max(26.0); + let step = [3, 6, 12, 24].into_iter().find(|s| h * *s as f32 / 60.0 >= widest.y).filter(|_| rect.width() - 8.0 - column >= n * 6.0 + 4.0 * (n - 1.0)); + let right = if step.is_some() { rect.max.x - column } else { rect.max.x - 8.0 }; + let area = Rect::from_min_max(pos2(rect.min.x + 8.0, top), pos2(right.max(rect.min.x + 8.0), bottom)); + let text_at = |text: &str, font: egui::FontId, at: egui::Pos2, align: Align2| align.anchor_size(at, size(text, font)); + let scale: Vec<(i32, Rect)> = step + .map(|s| { + (0..=57 / s) + .map(|k| { + let y = area.min.y + h * (k * s) as f32 / 60.0; + (-(k * s), text_at(&(-(k * s)).to_string(), Tokens::ui(SCALE_FONT), pos2(rect.max.x - 6.0, y), Align2::RIGHT_CENTER)) + }) + .collect() + }) + .unwrap_or_default(); + let (w, pitch) = bar_pitch(area, names.len()); + let feet_names: Vec = names + .iter() + .enumerate() + .map(|(c, name)| { + let (text, font) = foot(name, names.len()); + text_at(text, font, pos2(area.min.x + c as f32 * pitch + w / 2.0, area.max.y + 16.0), Align2::CENTER_CENTER) + }) + .collect(); + let names = if feet && feet_names.iter().all(|r| r.width() < pitch) { feet_names } else { Vec::new() }; + let unit = (feet && step.is_some()) + .then(|| text_at("dB", Tokens::ui(SCALE_FONT), pos2(rect.max.x - 6.0, area.max.y + 10.0), Align2::RIGHT_CENTER)) + .filter(|u| !names.iter().chain(scale.iter().map(|(_, r)| r)).any(|r| overlaps(*u, *r))); + let readout = readout.map(|units| (Rect::from_min_max(pos2(rect.min.x + 4.0, rect.max.y - lufs_h), pos2(rect.max.x - 4.0, rect.max.y - 4.0)), units)); + Parts { area, scale, unit, names, readout } +} + +/// The text under a bar of a meter with `n` bars, and its font: an "S" under each bar of a stereo +/// meter, the channel names under a 5.1 one. +fn foot(name: &str, n: usize) -> (&str, egui::FontId) { + if n == 2 { ("S", Tokens::ui(10.0)) } else { (name, Tokens::ui(SCALE_FONT)) } +} + +/// The width of a bar and the distance from one bar to the next. +fn bar_pitch(area: Rect, n: usize) -> (f32, f32) { + let n = n.max(1) as f32; + let gap = if area.width() >= n * 6.0 + 4.0 * (n - 1.0) { 4.0 } else { 1.0 }; + let w = ((area.width() - gap * (n - 1.0)) / n).max(1.0); + (w, w + gap) +} + +fn overlaps(a: Rect, b: Rect) -> bool { + let i = a.intersect(b); + i.width() > 0.0 && i.height() > 0.0 +} + pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; // levels of the Mix as it plays (fast attack, 20 dB/s release, peak hold) @@ -54,18 +144,17 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let names = filmcraft_audio_dsp::channels::Layout::from_channels(nch).names(); // Premiere: black meter area, scale 0 … −57 dB in 3 dB steps on the right, "dB" at the foot. // Loudness readout (BS.1770 / EBU R128) under the bars. - let lufs_h = if rect.height() > 260.0 { 64.0 } else { 0.0 }; - let area = Rect::from_min_max(pos2(rect.min.x + 8.0, rect.min.y + 8.0), pos2(rect.max.x - 26.0, rect.max.y - 30.0 - lufs_h)); - if lufs_h > 0.0 { + let parts = parts(rect, names, |text, font| ui.painter().layout_no_wrap(text.to_string(), font, t.text).size()); + let area = parts.area; + if let Some((lr, units)) = parts.readout { let (m, s, i, tp) = app.loudness.as_ref().map_or((f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY), |(l, _)| { (l.momentary(), l.short_term(), l.integrated(), l.true_peak_dbtp()) }); - let lr = Rect::from_min_max(pos2(rect.min.x + 4.0, rect.max.y - lufs_h), pos2(rect.max.x - 4.0, rect.max.y - 4.0)); ui.painter().rect_filled(lr, 2.0, t.meter_readout_bg); let rows = [("M", lufs_text(m)), ("S", lufs_text(s)), ("I", lufs_text(i)), ("TP", lufs_text(tp))]; for (k, (label, val)) in rows.iter().enumerate() { let y = lr.min.y + 8.0 + k as f32 * 14.0; - ui.painter().text(pos2(lr.min.x + 6.0, y), Align2::LEFT_CENTER, *label, Tokens::ui(9.5), t.text_dim); + ui.painter().text(pos2(lr.min.x + 6.0, y), Align2::LEFT_CENTER, *label, Tokens::ui(READOUT_FONT), t.text_dim); let hot = *label == "I" || (*label == "TP" && tp > -1.0); let col = if *label == "TP" && tp > -1.0 { egui::Color32::from_rgb(0xe0, 0x50, 0x40) @@ -74,38 +163,163 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } else { t.text }; - ui.painter().text( - pos2(lr.max.x - 6.0, y), - Align2::RIGHT_CENTER, - format!("{val} {}", if *label == "TP" { "dBTP" } else { "LUFS" }), - Tokens::mono(9.5), - col, - ); + let unit = match (units, *label == "TP") { + (false, _) => "", + (true, true) => " dBTP", + (true, false) => " LUFS", + }; + ui.painter().text(pos2(lr.max.x - 6.0, y), Align2::RIGHT_CENTER, format!("{val}{unit}"), Tokens::mono(READOUT_FONT), col); } app.auto.add("audioMeters.loudness", lr, &format!("M {} S {} I {} LUFS TP {} dBTP", lufs_text(m), lufs_text(s), lufs_text(i), lufs_text(tp))); } ui.painter().rect_filled(Rect::from_min_max(pos2(rect.min.x + 4.0, rect.min.y + 4.0), pos2(rect.max.x - 4.0, area.max.y + 4.0)), 0.0, t.meter_bg); - let w = ((area.width() - 4.0 * (nch as f32 - 1.0)) / nch as f32).max(3.0); - for c in 0..nch { - let r = Rect::from_min_size(pos2(area.min.x + c as f32 * (w + 4.0), area.min.y), vec2(w, area.height())); + let (w, pitch) = bar_pitch(area, names.len()); + for (c, name) in names.iter().enumerate() { + let r = Rect::from_min_size(pos2(area.min.x + c as f32 * pitch, area.min.y), vec2(w, area.height())); let m = st.get(c).copied().unwrap_or([-90.0; 2]); crate::widgets::meter_bar(ui.painter(), r, m[0], m[1], &t); - app.auto.add(&format!("audioMeters.channel.{}", names[c]), r, &format!("{} {:.1} dB", names[c], m[0])); + app.auto.add(&format!("audioMeters.channel.{name}"), r, &format!("{name} {:.1} dB", m[0])); } - let mut db = 0; - while db >= -57 { - let y = area.min.y + area.height() * (-db as f32 / 60.0); - ui.painter().text(pos2(rect.max.x - 6.0, y), Align2::RIGHT_CENTER, format!("{db}"), Tokens::ui(8.5), t.text_dim); - db -= 3; + for (db, r) in &parts.scale { + ui.painter().text(r.right_center(), Align2::RIGHT_CENTER, db.to_string(), Tokens::ui(SCALE_FONT), t.text_dim); } - ui.painter().text(pos2(rect.max.x - 6.0, area.max.y + 10.0), Align2::RIGHT_CENTER, "dB", Tokens::ui(8.5), t.text_dim); - for c in 0..nch { - let r = Rect::from_center_size(pos2(area.min.x + c as f32 * (w + 4.0) + w / 2.0, area.max.y + 16.0), vec2(14.0, 14.0)); - let label = if nch == 2 { "S" } else { names[c] }; - ui.painter().text(r.center(), Align2::CENTER_CENTER, label, Tokens::ui(if nch == 2 { 10.0 } else { 8.5 }), t.text_dim); + if let Some(r) = parts.scale.iter().map(|(_, r)| *r).reduce(|a, b| a.union(b)) { + let numbers: Vec = parts.scale.iter().map(|(db, _)| db.to_string()).collect(); + app.auto.add("audioMeters.scale", r, &numbers.join(" ")); + } + if let Some(r) = parts.unit { + ui.painter().text(r.right_center(), Align2::RIGHT_CENTER, "dB", Tokens::ui(SCALE_FONT), t.text_dim); + } + for (r, name) in parts.names.iter().zip(names) { + let (text, font) = foot(name, names.len()); + ui.painter().text(r.center(), Align2::CENTER_CENTER, text, font, t.text_dim); } if app.playback.playing { ui.ctx().request_repaint(); } app.auto.add("audioMeters", area, "Audio Meters"); } + +#[cfg(test)] +mod tests { + use super::*; + + /// Run `f` with a measure of text in egui's own fonts. + fn with_text_size(f: impl FnOnce(&dyn Fn(&str, egui::FontId) -> egui::Vec2)) { + let ctx = egui::Context::default(); + let mut f = Some(f); + ctx.run_ui(egui::RawInput::default(), |ui| { + if let Some(f) = f.take() { + f(&|text, font| ui.painter().layout_no_wrap(text.to_string(), font, egui::Color32::WHITE).size()); + } + }) + .textures_delta + .clear(); + } + + const STEREO: [&str; 2] = ["L", "R"]; + const SURROUND: [&str; 6] = ["L", "R", "C", "LFE", "Ls", "Rs"]; + + fn at(w: f32, h: f32) -> Rect { + Rect::from_min_size(pos2(100.0, 50.0), vec2(w, h)) + } + + /// The default size in the Editing workspace shows everything, as before. + #[test] + fn a_roomy_panel_shows_every_part() { + with_text_size(|size| { + let p = parts(at(116.0, 400.0), &STEREO, size); + assert_eq!(p.scale.iter().map(|(db, _)| *db).collect::>(), (0..20).map(|k| -3 * k).collect::>()); + assert!(p.unit.is_some()); + assert_eq!(p.names.len(), 2); + assert_eq!(p.readout.map(|r| r.1), Some(true), "readout with units"); + }); + } + + /// Narrowing drops the readout's units, then the readout, then the scale; the bars stay. + #[test] + fn narrowing_drops_readout_units_then_readout_then_scale() { + with_text_size(|size| { + let mut seen = Vec::new(); + for w in (40..=116).rev() { + let p = parts(at(w as f32, 400.0), &STEREO, size); + let stage = match (p.readout.map(|r| r.1), p.scale.is_empty()) { + (Some(true), false) => 0, + (Some(false), false) => 1, + (None, false) => 2, + (None, true) => 3, + other => panic!("{w}: {other:?}"), + }; + if seen.last() != Some(&stage) { + seen.push(stage); + } + assert!(p.area.width() >= 10.0, "{w}: bars stay readable"); + } + assert_eq!(seen, [0, 1, 2, 3]); + }); + } + + /// Getting shorter thins the scale out (3, 6, 12, 24 dB apart) and then hides it, and the + /// names under the bars go. (The scale gets denser again where the readout or the names + /// go and give the bars their room.) + #[test] + fn shortening_thins_the_scale_then_hides_it() { + with_text_size(|size| { + let step = |h: i32| { + let p = parts(at(116.0, h as f32), &STEREO, size); + match p.scale.as_slice() { + [] => 99, + [_] => 60, + [a, b, ..] => a.0 - b.0, + } + }; + let mut seen = Vec::new(); + for range in [261..=400, 90..=260, 10..=89] { + let mut steps: Vec = range.rev().map(step).collect(); + steps.dedup(); + assert!(steps.windows(2).all(|s| s[0] < s[1]), "{steps:?}"); + seen.extend(steps); + } + seen.sort(); + seen.dedup(); + assert_eq!(seen, [3, 6, 12, 24, 99]); + for h in 20..=400 { + assert_eq!(parts(at(116.0, h as f32), &STEREO, size).names.is_empty(), h < 90, "{h}"); + } + }); + } + + /// At any size no text overlaps other text, the bars or the readout, and everything stays + /// inside the panel. + #[test] + fn no_text_overlaps_at_any_size() { + with_text_size(|size| { + for names in [&STEREO[..], &SURROUND[..]] { + for w in (20..=400).step_by(3) { + for h in (10..=800).step_by(7) { + let rect = at(w as f32, h as f32); + let p = parts(rect, names, size); + let mut texts: Vec = p.scale.iter().map(|(_, r)| *r).chain(p.unit).chain(p.names.iter().copied()).collect(); + if let Some((r, units)) = p.readout { + for (k, label) in ["M", "S", "I", "TP"].into_iter().enumerate() { + let y = r.min.y + 8.0 + k as f32 * 14.0; + let value = if units { "-88.8 dBTP" } else { "-88.8" }; + let l = Align2::LEFT_CENTER.anchor_size(pos2(r.min.x + 6.0, y), size(label, Tokens::ui(READOUT_FONT))); + let v = Align2::RIGHT_CENTER.anchor_size(pos2(r.max.x - 6.0, y), size(value, Tokens::mono(READOUT_FONT))); + assert!(!overlaps(l, v) && r.contains_rect(l) && r.contains_rect(v), "{w}×{h} readout row {label}"); + } + texts.push(r); + } + for (i, a) in texts.iter().enumerate() { + assert!(rect.contains_rect(*a), "{w}×{h}: {a:?} outside {rect:?}"); + assert!(!overlaps(*a, p.area), "{w}×{h}: {a:?} over the bars {:?}", p.area); + for b in texts.iter().skip(i + 1) { + assert!(!overlaps(*a, *b), "{w}×{h} {}ch: {a:?} overlaps {b:?}", names.len()); + } + } + } + } + } + }); + } +} diff --git a/crates/ui-egui/src/panels/mixer.rs b/crates/ui-egui/src/panels/mixer.rs index bcc1b42af..49dfb9d7b 100644 --- a/crates/ui-egui/src/panels/mixer.rs +++ b/crates/ui-egui/src/panels/mixer.rs @@ -106,7 +106,8 @@ pub fn poll_meters(app: &mut FilmcraftApp, ui: &egui::Ui) -> HashMap; + +/// An output that keeps the callback it was started with, for the test to pull samples from. +struct Out { + fill: Arc>>, +} + +impl AudioOut for Out { + fn start(&mut self, fill: Fill) -> Result { + *self.fill.lock().unwrap() = Some(fill); + Ok(48_000) + } + fn stop(&mut self) { + *self.fill.lock().unwrap() = None; + } + fn sample_rate(&self) -> u32 { + 48_000 + } + fn played_frames(&self) -> Option { + None + } +} + +struct Driver { + harness: Harness<'static, FilmcraftApp>, + tx: Sender, + fill: Arc>>, +} + +impl Driver { + fn demo() -> Self { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).expect("demo project"); + session.prefs.audio.scrub_audio = true; + let (tx, rx) = channel(); + let fill = Arc::new(Mutex::new(None)); + let mut app = FilmcraftApp::new(session).with_control(rx); + app.audio = Some(Box::new(Out { fill: fill.clone() })); + 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, fill }; + 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 call(&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() { + return v; + } + } + panic!("no reply to {method} {params}"); + } + fn ok(&mut self, method: &str, params: Value) -> Value { + let r = self.call(method, params.clone()); + assert_eq!(r["ok"], true, "{method} {params}: {r}"); + r["result"].clone() + } + fn element(&mut self, id: &str) -> Option { + let v = self.ok("ui.elements", json!({"prefix": id})); + v.as_array().unwrap().iter().find(|e| e["id"] == id).cloned() + } + fn label(&mut self, id: &str) -> String { + self.element(id).and_then(|e| e["label"].as_str().map(str::to_string)).unwrap_or_default() + } + /// Give the Audio Meters panel `width` points (it sits in a fixed-width split beside the + /// Timeline). + fn meters_width(&mut self, width: f32) { + fn set(n: &mut DockNode, width: f32) -> bool { + match n { + DockNode::Split { size, b, .. } if matches!(&**b, DockNode::Tabs { panels, .. } if panels.as_slice() == [PanelKind::AudioMeters]) => { + *size = SplitSize::FixedB(width); + true + } + DockNode::Split { a, b, .. } => set(a, width) || set(b, width), + DockNode::Tabs { .. } => false, + } + } + assert!(set(&mut self.harness.state_mut().ui.dock, width)); + self.frames(3); + } + /// The level the left bar shows, in dB. + fn left_db(&mut self) -> f32 { + let label = self.label("audioMeters.channel.L"); + label.split_whitespace().nth(1).and_then(|v| v.parse().ok()).unwrap_or_else(|| panic!("label {label:?}")) + } +} + +/// As the panel narrows the loudness readout goes and then the dB scale, while the bars stay; at +/// the default width it shows all of them, the scale every 3 dB. +#[test] +fn a_narrow_meter_panel_drops_the_readout_and_then_the_scale() { + let mut d = Driver::demo(); + assert!(d.element("audioMeters.loudness").is_some(), "the readout at the default width"); + assert_eq!(d.label("audioMeters.scale").split(' ').count(), 20, "0 to -57 every 3 dB"); + d.meters_width(60.0); + assert!(d.element("audioMeters.loudness").is_none(), "no room for the readout"); + assert!(d.element("audioMeters.scale").is_some()); + d.meters_width(44.0); + assert!(d.element("audioMeters.scale").is_none(), "no room for the scale"); + for name in ["L", "R"] { + let bar = d.element(&format!("audioMeters.channel.{name}")).unwrap(); + assert!(bar["rect"][2].as_f64().unwrap() >= 5.0, "the {name} bar stays readable: {bar}"); + } + d.meters_width(116.0); + assert!(d.element("audioMeters.loudness").is_some()); +} + +/// Dragging the playhead along the ruler with audio scrubbing on plays grains of the sequence, and +/// the meters show them, as they do in playback. +#[test] +fn the_meters_move_while_scrubbing() { + let mut d = Driver::demo(); + assert_eq!(d.left_db(), -90.0, "silent before"); + let ruler = d.element("timeline.ruler").expect("the Timeline ruler"); + let r = &ruler["rect"]; + let (x, y, w) = (r[0].as_f64().unwrap() as f32, r[1].as_f64().unwrap() as f32 + r[3].as_f64().unwrap() as f32 / 2.0, r[2].as_f64().unwrap() as f32); + let (from, to) = (egui::pos2(x + w * 0.2, y), egui::pos2(x + w * 0.3, y)); + d.harness.input_mut().events.push(egui::Event::PointerMoved(from)); + d.harness.input_mut().events.push(egui::Event::PointerButton { + pos: from, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: Default::default(), + }); + d.frames(1); + d.harness.input_mut().events.push(egui::Event::PointerMoved(to)); + // the playhead moves in this frame; the next one hears it + d.frames(2); + // the device asks for the grain + { + let mut fill = d.fill.lock().unwrap(); + let f = fill.as_mut().expect("scrubbing opened the output"); + let mut buf = vec![0.0; 4096 * 2]; + f(&mut buf, 2); + assert!(buf.iter().any(|s| *s != 0.0), "the grain has sound"); + } + d.frames(2); + let db = d.left_db(); + assert!(db > -60.0, "the meter shows the grain: {db} dB"); + d.harness.input_mut().events.push(egui::Event::PointerButton { + pos: to, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: Default::default(), + }); + d.frames(1); +} From 2ea2e24cd2452cadc21fcbe90fae589ad3f77800 Mon Sep 17 00:00:00 2001 From: ReReddo <249398616+ReReddo@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:45:55 +0300 Subject: [PATCH 208/260] tools: press and drag a grouped tool to open its flyout Pressing on a grouped tool and dragging the pointer a few points in any direction opens its flyout straight away, without waiting for the long press. Letting go over a flyout row picks that tool and closes the flyout; letting go anywhere else leaves it open, as after a long press. Right-click and the 0.35 s long press work as before. --- crates/ui-egui/src/panels/tools.rs | 80 ++++++++++++++++++++++++++++-- 1 file changed, 75 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/panels/tools.rs b/crates/ui-egui/src/panels/tools.rs index 15228a502..34106c6b9 100644 --- a/crates/ui-egui/src/panels/tools.rs +++ b/crates/ui-egui/src/panels/tools.rs @@ -1,5 +1,5 @@ -//! The vertical Tools panel. Grouped tools show a small flyout triangle; right-click (or long -//! press) opens the group to pick another tool. +//! The vertical Tools panel. Grouped tools show a small flyout triangle; right-click, a long +//! press or pressing and dragging opens the group to pick another tool. use egui::{Rect, Sense, pos2, vec2}; @@ -23,7 +23,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } else { format!("{} ({})", app.ui.language.tr(current.label()), current.shortcut()) }; - let resp = ui.interact(r, egui::Id::new(("tool", format!("{:?}", group[0]))), Sense::click()).on_hover_text(tip); + let resp = ui.interact(r, egui::Id::new(("tool", format!("{:?}", group[0]))), Sense::click_and_drag()).on_hover_text(tip); resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, resp.enabled(), app.ui.tool == current, current.label())); for tl in &group { if *tl == current { @@ -58,7 +58,9 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { { ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(0.35 - seconds)); } - let long_press = held_for.is_some_and(|seconds| seconds >= 0.35) && !held; + // dragging off the press (egui's few-point drag threshold) opens it at once, without + // waiting for the long press + let long_press = (held_for.is_some_and(|seconds| seconds >= 0.35) || resp.drag_started()) && !held; let command = if resp.secondary_clicked() || long_press { if long_press { ui.data_mut(|d| d.insert_temp(held_id, resp.id)); @@ -74,7 +76,9 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { for tl in &group { let row = ui.selectable_label(app.ui.tool == *tl, format!("{} {}", app.ui.language.tr(tl.label()), tl.shortcut())); app.auto.add_response(&format!("tools.select.{tl:?}"), &row, tl.label()); - if row.clicked() { + // letting go over a row of a flyout this press opened picks that tool + let released_on = held && ui.input(|i| i.pointer.primary_released() && i.pointer.interact_pos().is_some_and(|p| row.rect.contains(p))); + if row.clicked() || released_on { app.ui.tool = *tl; ui.close(); } @@ -101,6 +105,72 @@ mod tests { out.textures_delta.clear(); } + fn center_of(app: &FilmcraftApp, id: &str) -> Option { + let [x, y, w, h] = app.auto.elements.iter().find(|w| w.id == id)?.rect; + Some(egui::pos2(x + w / 2.0, y + h / 2.0)) + } + + fn primary(pos: egui::Pos2, pressed: bool) -> egui::Event { + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() } + } + + /// The panel shown twice, then the button pressed on the Type tool group and dragged by `by` + /// within 0.1 s, well before a long press. + fn press_and_drag_type(by: egui::Vec2) -> (FilmcraftApp, egui::Context) { + let mut app = FilmcraftApp::new(filmcraft_engine::Session::default()); + let ctx = egui::Context::default(); + crate::theme::install(&ctx, &app.tokens); + frame(&mut app, &ctx, 0.0, vec![]); + frame(&mut app, &ctx, 0.1, vec![]); + let at = center_of(&app, "tools.Type").unwrap(); + frame(&mut app, &ctx, 1.0, vec![egui::Event::PointerMoved(at), primary(at, true)]); + frame(&mut app, &ctx, 1.05, vec![egui::Event::PointerMoved(at + by)]); + frame(&mut app, &ctx, 1.1, vec![]); + (app, ctx) + } + + #[test] + fn dragging_off_a_grouped_tool_opens_its_flyout_and_letting_go_on_a_row_picks_it() { + let (mut app, ctx) = press_and_drag_type(vec2(-6.0, 3.0)); + let row = center_of(&app, "tools.select.VerticalType").expect("the drag opened the flyout before a long press"); + frame(&mut app, &ctx, 1.15, vec![egui::Event::PointerMoved(row)]); + frame(&mut app, &ctx, 1.2, vec![primary(row, false)]); + frame(&mut app, &ctx, 1.25, vec![]); + assert_eq!(app.ui.tool, Tool::VerticalType); + assert!(center_of(&app, "tools.select.VerticalType").is_none(), "picking a tool closes the flyout"); + } + + #[test] + fn letting_go_of_a_dragged_open_flyout_elsewhere_leaves_it_open() { + let (mut app, ctx) = press_and_drag_type(vec2(5.0, 5.0)); + let away = pos2(600.0, 700.0); + frame(&mut app, &ctx, 1.15, vec![egui::Event::PointerMoved(away)]); + frame(&mut app, &ctx, 1.2, vec![primary(away, false)]); + frame(&mut app, &ctx, 1.25, vec![]); + assert_eq!(app.ui.tool, Tool::Selection); + let row = center_of(&app, "tools.select.VerticalType").expect("the flyout stays open, as after a long press"); + // and a click on one of its rows still picks it + frame(&mut app, &ctx, 2.0, vec![egui::Event::PointerMoved(row), primary(row, true)]); + frame(&mut app, &ctx, 2.05, vec![primary(row, false)]); + assert_eq!(app.ui.tool, Tool::VerticalType); + } + + #[test] + fn a_right_click_on_a_grouped_tool_still_opens_its_flyout() { + let mut app = FilmcraftApp::new(filmcraft_engine::Session::default()); + let ctx = egui::Context::default(); + crate::theme::install(&ctx, &app.tokens); + frame(&mut app, &ctx, 0.0, vec![]); + frame(&mut app, &ctx, 0.1, vec![]); + let at = center_of(&app, "tools.Type").unwrap(); + let secondary = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, 1.0, vec![egui::Event::PointerMoved(at), secondary(true)]); + frame(&mut app, &ctx, 1.05, vec![secondary(false)]); + frame(&mut app, &ctx, 1.1, vec![]); + assert!(center_of(&app, "tools.select.VerticalType").is_some()); + assert_eq!(app.ui.tool, Tool::Selection); + } + #[test] fn long_press_type_selects_vertical_without_selecting_on_release() { let mut app = FilmcraftApp::new(filmcraft_engine::Session::default()); From 5c11f847705e1e39925fb03f247a39a538c6f64f Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:35:08 +0200 Subject: [PATCH 209/260] Lumetri panel: truncate a long clip name in the header so the control rows stay in view (#997) --- crates/ui-egui/src/panels/lumetri.rs | 5 ++++- crates/ui-egui/tests/color_ui.rs | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/lumetri.rs b/crates/ui-egui/src/panels/lumetri.rs index ff35ec199..2364d97fa 100644 --- a/crates/ui-egui/src/panels/lumetri.rs +++ b/crates/ui-egui/src/panels/lumetri.rs @@ -51,13 +51,16 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let mt = it.source_time_at(ph.clamp(it.start, it.end() - Tick(1))); let mut actions: Actions = Vec::new(); // header: clip context + fx toggle + // the fx toggle is laid out first so a long clip name is truncated instead of widening the panel bui.horizontal(|ui| { - ui.label(egui::RichText::new(tlf!("Master · {name}", name = it.name)).color(t.text_dim)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let fx = ui.add(egui::Button::new(egui::RichText::new("fx").italics().color(if e.enabled { t.text } else { t.text_faint })).frame(false)); if fx.clicked() { let _ = app.session.execute("effects.toggleEnabled", json!({"clip": clip.0, "index": idx})); } + ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui.add(egui::Label::new(egui::RichText::new(tlf!("Master · {name}", name = it.name)).color(t.text_dim)).truncate()); + }); }); }); let set = |actions: &mut Actions, param: &str, v: Value| actions.push(json!({"clip": clip.0, "effect": idx, "param": param, "value": v})); diff --git a/crates/ui-egui/tests/color_ui.rs b/crates/ui-egui/tests/color_ui.rs index 1ea047c84..bccaefc69 100644 --- a/crates/ui-egui/tests/color_ui.rs +++ b/crates/ui-egui/tests/color_ui.rs @@ -347,3 +347,20 @@ fn lumetri_presets_drag_onto_a_timeline_clip() { d.exec("edit.undo", json!({})); assert_eq!(lumetri(&mut d, target), Value::Null); } + +#[test] +fn lumetri_rows_keep_their_width_when_the_clip_name_is_long() { + let mut d = Driver::demo(); + d.ok("ui.set", json!({"workspace": "Color"})); + d.frames(3); + let clip = select_first_clip(&mut d); + d.exec("effects.apply", json!({"clips": [clip], "effect": "lumetri"})); + d.exec("clip.rename", json!({"clip": clip, "name": "short.png"})); + d.exec("sequence.colorSettings", json!({"workingSpace": "rec2100-pq"})); + d.frames(3); + let short = d.element("lumetri.param.hdr_white").expect("hdr_white row"); + d.exec("clip.rename", json!({"clip": clip, "name": "inference_output_rollout_2026-10-04_3ce159cb-52e3-4b72-9e90-0c4a1f2b3d4e_with_a_very_long_tail.mp4"})); + d.frames(3); + let long = d.element("lumetri.param.hdr_white").expect("hdr_white row"); + assert_eq!(short, long, "a long clip name must not move or widen the control rows"); +} From d702608eaf9ab8c33ac03c55dd7eeb2564f7912f Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:35:18 +0200 Subject: [PATCH 210/260] Count VideoToolbox sessions, frames and declined requests in perf.stats (#981) --- crates/platform/src/hardware_encode.rs | 8 +++++++- crates/platform/tests/hardware_encode.rs | 19 ++++++++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/platform/src/hardware_encode.rs b/crates/platform/src/hardware_encode.rs index c715e927f..eaf7e0dc6 100644 --- a/crates/platform/src/hardware_encode.rs +++ b/crates/platform/src/hardware_encode.rs @@ -133,13 +133,18 @@ pub fn videotoolbox_encoder_factory( Ok(c) => c, Err(why) => { log::info!("hardware {name} encoding declined: {why}"); + filmcraft_export::note_hw_encode_declined(); return None; } }; match VtEncoder::new(config) { - Ok(vt) => Some(Ok(Box::new(HardwareEncoder { vt, y: Vec::new(), u: Vec::new(), v: Vec::new(), decoded: 0 }))), + Ok(vt) => { + filmcraft_export::note_hw_encode_session(); + Some(Ok(Box::new(HardwareEncoder { vt, y: Vec::new(), u: Vec::new(), v: Vec::new(), decoded: 0 }))) + } Err(why) => { log::info!("hardware {name} encoding declined: {why}"); + filmcraft_export::note_hw_encode_declined(); None } } @@ -222,6 +227,7 @@ impl VideoEncoder for HardwareEncoder { } filmcraft_export::timed(filmcraft_export::Stage::Convert, || filmcraft_export::rgba_to_yuv420_8(f.rgba, w, h, &mut self.y, &mut self.u, &mut self.v)); let packets = self.vt.encode(f.index, &self.y, &self.u, &self.v).map_err(ExportError::Encode)?; + filmcraft_export::note_hw_encode_frame(); // the muxer needs the parameter sets after the first group of pictures if f.index == 0 { let params = self.vt.parameter_sets().ok_or_else(|| ExportError::Encode("VideoToolbox returned no parameter sets for the first frame".into()))?; diff --git a/crates/platform/tests/hardware_encode.rs b/crates/platform/tests/hardware_encode.rs index 94b1b928a..e4959415d 100644 --- a/crates/platform/tests/hardware_encode.rs +++ b/crates/platform/tests/hardware_encode.rs @@ -9,7 +9,8 @@ use std::panic::AssertUnwindSafe; use std::sync::Arc; use filmcraft_export::{ - BitrateMode, EncodedPacket, EncoderFrame, ExportSettings, Format, H264Pass, H264Profile, HardwareEncoding, Progress, VideoEncoder, export, rgba_to_yuv420_8, + BitrateMode, EncodedPacket, EncoderFrame, ExportSettings, Format, H264Pass, H264Profile, HardwareEncoding, Progress, VideoEncoder, export, hw_encode_stats, + rgba_to_yuv420_8, }; use filmcraft_frame::PixelData; use filmcraft_isobmff::{CodecConfig, SampleEntry}; @@ -133,6 +134,22 @@ fn factory_only_acts_when_asked() { assert!(videotoolbox_encoder_factory(Format::H264, W, H, FPS, &ExportSettings { hardware_encoding: HardwareEncoding::Off, ..s }).is_none()); } +/// `perf.stats` reads these counters: a declined request, a session and every encoded picture must show. +/// The counters are process-wide and other tests add to them, so only increases are checked. +#[test] +fn hardware_encoder_counters_follow_the_export() { + let s = hardware_settings(); + let before = hw_encode_stats(); + // an odd picture size is declined before any session is created + assert!(videotoolbox_encoder_factory(Format::H264, W + 1, H, FPS, &s).is_none()); + assert!(hw_encode_stats().declined > before.declined, "a declined request is counted"); + let Some(mut enc) = hardware(&s, W, H) else { return }; + let created = hw_encode_stats(); + assert!(created.sessions > before.sessions, "a created session is counted"); + encode_all(&mut enc, 3); + assert!(hw_encode_stats().frames >= created.frames + 3, "every encoded picture is counted"); +} + #[test] fn round_trip_through_our_software_decoder() { let s = hardware_settings(); From 41ea382bc6941678fb335044b756d22452a01cad Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:35:52 +0200 Subject: [PATCH 211/260] Reject non-finite numbers and empty LUT input ranges in .cube import (#998) --- crates/color/src/lut.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/color/src/lut.rs b/crates/color/src/lut.rs index 92e919738..ac5642ac7 100644 --- a/crates/color/src/lut.rs +++ b/crates/color/src/lut.rs @@ -186,7 +186,7 @@ impl LutFormat { } fn floats(line: &str) -> Result, String> { - line.split_whitespace().map(|x| x.parse::().map_err(|_| format!("not a number: {x:?}"))).collect() + line.split_whitespace().map(|x| x.parse::().ok().filter(|v| v.is_finite()).ok_or_else(|| format!("not a finite number: {x:?}"))).collect() } impl Lut { @@ -252,6 +252,9 @@ impl Lut { if v.len() != 2 { return Err(err(&format!("{key} needs 2 values"))); } + if v[1] <= v[0] { + return Err(err(&format!("{key} maximum must exceed its minimum"))); + } let r = Some([v[0], v[1]]); if key == "LUT_1D_INPUT_RANGE" { range1 = r } else { range3 = r } } @@ -591,6 +594,16 @@ mod tests { assert!(Lut::parse_cube("TITLE \"x\"\n").is_err()); assert!(Lut::parse_cube("LUT_3D_SIZE 2\n0 0 0\nLUT_1D_SIZE 2\n").is_err()); assert!(Lut::parse_cube("LUT_3D_SIZE 2\nDOMAIN_MIN 1 1 1\nDOMAIN_MAX 0 0 0\n0 0 0\n0 0 0\n0 0 0\n0 0 0\n0 0 0\n0 0 0\n0 0 0\n0 0 0\n").is_err()); + for key in ["LUT_1D_INPUT_RANGE", "LUT_3D_INPUT_RANGE"] { + let (size, rows) = if key.starts_with("LUT_1D") { ("LUT_1D_SIZE 2", 2) } else { ("LUT_3D_SIZE 2", 8) }; + let table = "0 0 0\n".repeat(rows); + for range in ["0 0", "1 0", "NaN 1", "0 inf"] { + assert!(Lut::parse_cube(&format!("{size}\n{key} {range}\n{table}")).is_err(), "accepted {key} {range}"); + } + assert!(Lut::parse_cube(&format!("{size}\n{key} -0.5 2\n{table}")).is_ok()); + } + assert!(Lut::parse_cube("LUT_1D_SIZE 2\n0 0 0\nNaN 1 1\n").is_err()); + assert!(Lut::parse_cube("LUT_1D_SIZE 2\nDOMAIN_MAX inf 1 1\n0 0 0\n1 1 1\n").is_err()); // comments, blank lines, Windows newlines, inline comments let txt = "# hi\r\nTITLE \"t\"\r\n\r\nLUT_3D_SIZE 2 # two\r\n0 0 0\n1 0 0\n0 1 0\n1 1 0\n0 0 1\n1 0 1\n0 1 1\n1 1 1\n"; let l = Lut3d::parse_cube(txt).unwrap(); From da4a0866766f928b8c38c0da3b71d958f7810355 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:35:53 +0200 Subject: [PATCH 212/260] Keep the voice-over start point when recording past the end of the timeline (#960) --- crates/ui-egui/src/lib.rs | 42 ++++++++++++++++++++++++++-- crates/ui-egui/tests/voiceover_ui.rs | 21 ++++++++++++++ docs/architecture.md | 2 +- docs/testing.md | 2 +- 4 files changed, 63 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 438112a29..9a62b1d08 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -891,7 +891,12 @@ impl FilmcraftApp { // restart from the end → from the start (Settings ▸ Timeline ▸ "At playback end, return to // beginning when restarting playback") let dur = self.session.active_sequence().map(|q| q.duration()).unwrap_or_default(); - if speed > 0.0 && self.session.prefs.timeline.return_to_beginning && self.session.playhead() >= dur - self.session.sequence_rate().frame_duration() { + // (not while a voice-over take starts: it was put exactly where the take must begin, e.g. on empty timeline past the end) + if speed > 0.0 + && self.session.prefs.timeline.return_to_beginning + && !self.session.voiceover.recording() + && self.session.playhead() >= dur - self.session.sequence_rate().frame_duration() + { self.session.set_playhead(Tick::ZERO); } // A loop restart keeps counting into the same meter. @@ -1081,7 +1086,8 @@ impl FilmcraftApp { if let Some(end) = self.playback.stop_at.filter(|e| t >= *e && self.playback.speed > 0.0 && !self.playback.looping) { self.session.set_playhead(end); self.stop(); - } else if t >= hi && self.playback.speed > 0.0 { + } else if t >= hi && self.playback.speed > 0.0 && (self.playback.looping || !self.session.voiceover.recording()) { + // a voice-over take keeps playing (and recording) into the empty timeline past the end if self.playback.looping { self.session.set_playhead(lo); self.play(self.playback.speed); @@ -2226,6 +2232,38 @@ mod audio_recovery_tests { } } +#[cfg(test)] +mod voiceover_playback_tests { + use super::*; + + #[test] + fn a_voice_over_take_records_into_the_empty_timeline_past_the_end() { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).unwrap(); + session.execute("audio.voiceover.settings", json!({"prerollSeconds": 0.0})).unwrap(); + session.prefs.timeline.return_to_beginning = true; + let ctx = egui::Context::default(); + let mut app = FilmcraftApp::new(session); + app.audio = None; + let dur = app.session.active_sequence().map(|q| q.duration()).unwrap(); + let from = app.session.sequence_rate().snap(dur + Tick::from_seconds_f64(60.0)); + app.session.set_playhead(from); + panels::voiceover::toggle(&mut app, None).unwrap(); + // play() keeps the record point instead of returning to the beginning + assert!(app.playback.playing); + assert_eq!(app.session.playhead(), from); + app.end_preroll(0.0); + // and one second later the take is still playing and recording, not stopped at the end + let mut output = ctx.run_ui(egui::RawInput { time: Some(1.0), ..Default::default() }, |ui| app.advance_playback(ui.ctx())); + output.textures_delta.clear(); + assert!(app.playback.playing); + assert!(app.session.voiceover.recording()); + assert!(app.session.playhead() > from, "{:?} <= {from:?}", app.session.playhead()); + app.session.execute("audio.voiceover.stop", json!({"discard": true})).unwrap(); + app.stop(); + } +} + /// A physical-pixel screen position as a point in the window's UI space, given the window's content origin in points. /// /// Assumes the whole virtual screen uses the window's `pixels_per_point`: with monitors of mixed diff --git a/crates/ui-egui/tests/voiceover_ui.rs b/crates/ui-egui/tests/voiceover_ui.rs index 3d1e33a8e..193e3e3ec 100644 --- a/crates/ui-egui/tests/voiceover_ui.rs +++ b/crates/ui-egui/tests/voiceover_ui.rs @@ -174,3 +174,24 @@ fn microphone_button_records_onto_its_track() { assert_eq!(d.ok("ui.inspect", json!({}))["playback"]["playing"], json!(false)); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn recording_past_the_end_of_the_timeline_starts_there_not_at_the_beginning() { + let mut d = Driver::demo(); + let dir = std::env::temp_dir().join(format!("filmcraft-vo-ui-end-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + d.exec("file.projectSettings.scratchDisks", json!({"captured": dir.to_string_lossy()})); + d.exec("audio.voiceover.settings", json!({"prerollSeconds": 0.5})); + d.harness.state_mut().session.prefs.timeline.return_to_beginning = true; + // far beyond the last clip: empty timeline + d.exec("playhead.set", json!({"seconds": 10_000.0})); + d.click("timeline.track.A2.voiceover"); + let rec = d.exec("audio.voiceover.settings", json!({}))["recording"].clone(); + assert!(rec.is_object(), "recording started: {rec}"); + let ps = d.ok("ui.inspect", json!({})); + assert_eq!(ps["playback"]["playing"], json!(true)); + let playhead = ps["playhead"].as_i64().unwrap(); + assert!(playhead > 9_000 * 254_016_000_000i64, "playhead jumped back to {playhead}"); + d.click("timeline.track.A2.voiceover"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/docs/architecture.md b/docs/architecture.md index daaa470f0..6a26b77b5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -457,7 +457,7 @@ stereo / mono track ──5.1 panner───┼─► 5.1 submix / 5.1 Mix ─ ### 5.1.2 Voice-over recording -**Voice-over recording** (`engine::voiceover`, `audio.voiceover.*`). The record point R is the playhead, or the In point when In/Out are set (punch-in; punch-out at Out). Playback starts the pre-roll before it (C = max(0, R − pre-roll)) and the input is captured from C. When the UI's audio clock really starts, `audio.voiceover.sync` restarts the capture there. `audio.voiceover.stop` keeps the audio from R to min(stop, Out), writes a mono 32-bit float WAV ` .wav` (Scratch Disks ▸ Captured, else next to the project, else the data or temporary directory), imports it and overwrites it onto the record track at R as one undo step. The record track is the given one, else the record-armed one, else the first targeted one. Input goes through the `AudioInput` trait: cpal in the desktop app (`apps/filmcraft/src/audio_in.rs`, on its own thread), and `SyntheticInput` headless, which produces exactly the samples the timeline asks for so recordings land sample-accurately. Voice-Over Record Settings (Source, Input channel, Name, Countdown Sound Cues, Pre-/Post-roll) are preferences (`voiceOver`). The track header's microphone records or stops, and a right-click opens the settings dialog. Countdown beeps (1 kHz, 100 ms, each whole second of pre-roll and at R) are mixed into playback, an overlay counts down, and playback stops at Out + post-roll. +**Voice-over recording** (`engine::voiceover`, `audio.voiceover.*`). The record point R is the playhead, or the In point when In/Out are set (punch-in; punch-out at Out). Playback starts the pre-roll before it (C = max(0, R − pre-roll)) and the input is captured from C. When the UI's audio clock really starts, `audio.voiceover.sync` restarts the capture there. `audio.voiceover.stop` keeps the audio from R to min(stop, Out), writes a mono 32-bit float WAV ` .wav` (Scratch Disks ▸ Captured, else next to the project, else the data or temporary directory), imports it and overwrites it onto the record track at R as one undo step. The record track is the given one, else the record-armed one, else the first targeted one. Input goes through the `AudioInput` trait: cpal in the desktop app (`apps/filmcraft/src/audio_in.rs`, on its own thread), and `SyntheticInput` headless, which produces exactly the samples the timeline asks for so recordings land sample-accurately. Voice-Over Record Settings (Source, Input channel, Name, Countdown Sound Cues, Pre-/Post-roll) are preferences (`voiceOver`). The track header's microphone records or stops, and a right-click opens the settings dialog. Countdown beeps (1 kHz, 100 ms, each whole second of pre-roll and at R) are mixed into playback, an overlay counts down, and playback stops at Out + post-roll. A take records anywhere, also on the empty timeline past the end of the sequence: starting it never returns the playhead to the beginning, and playback keeps going past the end until the take is stopped. ### 5.1.2a Text to Speech (narrations) diff --git a/docs/testing.md b/docs/testing.md index 856dad73f..98ab6b6e6 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -30,7 +30,7 @@ FILMCRAFT_REQUIRE_ORACLES=1 cargo test --workspace # CI: missing ffmpeg fails | Mixer | `crates/render/src/mixer_tests.rs`, `crates/engine/src/mixer_tests.rs` | sample-exact fader gain and pan laws, automation at block boundaries (bit-identical however requests are cut), solo/mute/solo-safe, sends and submix routing, latency-compensation alignment, render-vs-playback identity, Touch ramp-back, recorder modes (Latch/Touch/Write), thinning, Audio Gain, transition curves; `perf_24_tracks_3_effects_realtime_factor` prints the realtime factor | | 5.1 / multichannel | `crates/audio-dsp/src/channels.rs`, `crates/render/src/mixer_tests.rs`, `crates/export/src/surround_tests.rs`, `crates/engine/src/mixer_tests.rs`, `crates/ui-egui/tests/audio51_ui.rs` | BS.775 downmix / upmix coefficients exact, mixdown types; 5.1 panner point gains equal-power (Σg² = 1 over a 21×21 grid × 3 centre settings), speaker positions exact, 5.1 through the default puck bit-exact identity; graph: stereo/mono tracks into a 5.1 Mix (L/R, C, rear right, room centre −6 dB each, LFE = mean), 5.1 track through a 5.1 Mix and folded into stereo, a 5.1 submix, `pan51` automation block-invariant, six-channel meters; export: 6-ch WAV (`WAVE_FORMAT_EXTENSIBLE`, mask 0x3F) and ProRes MOV (6 × 24-bit PCM) decoded by ffmpeg **sample-exact**, ffprobe `channels` 6 / `channel_layout` 5.1; AAC 5.1 in MP4: every decoded channel's own tone ≥ 30 dB above the others' (no swapped channels), stereo export of a 5.1 Mix = BS.775 within 2·10⁻⁶; `sequence.settings {mix}`, `file.newSequence {mix, trackType}`, panner lanes, Write pass records the puck; UI: puck drag (one undo step), Center / LFE, six meter bars | | Audio Clip Mixer automation, Track Mixer view | `crates/engine/src/mixer_tests.rs`, `crates/ui-egui/tests/audio51_ui.rs` | Touch: a 201-point fader ramp thins to ≤ 6 clip keyframes, values within 0.1 dB, live override while held, values outside the gesture unchanged, one undo step; Latch holds until stop; Write records volume and pan from the start; a gesture across two clips writes both; Read commits once without a pass; UI: Clip Mixer mode dropdown, a recorded fader drag writes clip keyframes; Show/Hide Tracks, Meter Input(s) Only, transport record button | -| Voice-over | `crates/engine/src/voiceover_tests.rs`, `crates/ui-egui/tests/voiceover_ui.rs` | synthetic click train lands in `mix_sequence` exactly at R + k·period (pre-roll dropped, exact sample count), punch-in between In/Out (ramp source, every sample checked), one undo step + undo/redo, take numbering, record-arm/targeting track choice, sync/discard/zero-length/locked cases, cue times; UI: right-click Mic → Voice-Over Record Settings dialog (edits persist on OK, Cancel discards), the Mic button starts and stops a take on its track with pre-roll and overlay | +| Voice-over | `crates/engine/src/voiceover_tests.rs`, `crates/ui-egui/tests/voiceover_ui.rs` | synthetic click train lands in `mix_sequence` exactly at R + k·period (pre-roll dropped, exact sample count), punch-in between In/Out (ramp source, every sample checked), one undo step + undo/redo, take numbering, record-arm/targeting track choice, sync/discard/zero-length/locked cases, cue times; UI: right-click Mic → Voice-Over Record Settings dialog (edits persist on OK, Cancel discards), the Mic button starts and stops a take on its track with pre-roll and overlay, and records past the end of the timeline (also `voiceover_playback_tests` in `crates/ui-egui/src/lib.rs`) | | Text to Speech | `crates/tts/src/**/*.rs` (Kokoro tests need `FILMCRAFT_KOKORO_DIR`), `crates/tts-text`, `crates/engine/src/narration_tests.rs`, `crates/ui-egui/tests/tts_ui.rs` | built-in voices: determinism, finite samples, exact-silence pause markers, pace scales length, measured pitch per voice and per semitone, hostile scripts; engine: narration placed at the playhead with clip length = speech (sample-exact), never overwrites (busy tracks → next free track → new track), occupied/locked/unknown track refused with no change, edit keeps start/length/speed (longer speech: whole speech in the file; shorter: file padded with exact zeros), edits write beside the previous file, undo/redo restore the project exactly, enabled only with a narration clip (or `clip`), 16 hostile parameter sets refused without change, preview changes nothing, save/reload keeps narrations, the narration is audible in `mix_sequence`; UI: type + Add pause + Add to timeline, the new clip is selected and loaded, clicking the clip on the timeline loads its script, Save edits it in place as one undo step, Advanced twirl, panel opens beside Properties; natural voices: 50-sentence Whisper-base WER 2.1–3.6 % for all 9 voices (crate README), loaders fuzzed with hostile files, `tts.render` cache, download refused without the feature | | Remix | `crates/audio-dsp/src/remix.rs`, `crates/render/src/remix.rs`, `crates/engine/src/remix_tests.rs`, `crates/ui-egui/tests/remix_ui.rs` | generated rhythmic music (Rust-synthesised drums, bass, chord sections at 90–128 BPM, 22.05/44.1/48 kHz): tempo within 0.3 BPM, every beat within 10 ms (measured ≤ 8.9 ms); remix targets from 0.4× to 2.2× the source within one beat (measured ≤ 12 ms at default sliders), every cut on a detected beat, intro and outro kept, pieces ≥ 4 beats, deterministic; silence, tone and noise refused; rendering independent of request cuts, source-exact outside the 20 ms equal-power crossfades, no clicks; the engine's mix equals the source pieces (< 1e-5); undo, redo and revert exact; overlaps refused; Remix Properties dialog and Remix tool drag driven by automation id | | Oracle | `crates/*/tests/*oracle*.rs`, `conformance.rs` | compare with ffmpeg/ffprobe (§2) | From 2d4422f408f5aa320564f139c645d4aa81f816c5 Mon Sep 17 00:00:00 2001 From: JordyUSA <75485565+JordyUSA@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:57:45 +0700 Subject: [PATCH 213/260] #965: HDR and wide-gamut media play on the GPU path On a Rec. 709 sequence every PQ, HLG, BT.2020 or P3 clip left the GPU path: each frame was converted to a float image on the CPU (curve, HLG OOTF, gamut matrix, BT.2390 tone mapping, desaturation) and resampled there. A 2160x3840 10-bit HLG clip took 90 ms per frame (48 ms scaled to fit a 1080x1920 sequence) against 33 ms at 30 fps; the same clip in SDR took 18 ms. The compositor's shader now runs that input transform itself: InputTransform::gpu_params gives its stages as numbers (log curves and camera-gamut compression stay on the CPU), PlanLayer::input carries the transform for Y'CbCr layers on a plain pipeline, and execute_cpu applies the same transform as the CPU reference. HDR clips now plan in 15-19 ms per frame, and the GPU result is within one output code of the CPU's for HLG, PQ (with and without a mastering peak) and BT.2020 SDR. Co-Authored-By: Claude Opus 5.5 --- crates/color/src/lib.rs | 2 +- crates/color/src/transform.rs | 67 ++++++++++++++++++++++++ crates/gpu/src/blend_tests.rs | 25 ++++++--- crates/gpu/src/composite.wgsl | 87 ++++++++++++++++++++++++++++++- crates/gpu/src/fx_tests.rs | 3 ++ crates/gpu/src/lib.rs | 51 +++++++++++++++--- crates/gpu/src/tests.rs | 68 +++++++++++++++++++++--- crates/render/src/colorman.rs | 17 +++++- crates/render/src/plan.rs | 40 ++++++++++---- crates/render/src/region_tests.rs | 25 +++++++++ crates/ui-egui/src/frames.rs | 3 +- crates/ui-egui/src/lib.rs | 10 +++- 12 files changed, 361 insertions(+), 37 deletions(-) diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 2ad1fa5f2..ce3340162 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -20,7 +20,7 @@ pub use grade::GradeSpace; pub use log::LogCurve; pub use lut::{Lut, Lut1d, Lut3d, LutFormat}; pub use spaces::{ColorPipeline, ColorSpace, Curve, Gamut, WorkingSpace}; -pub use transform::{DecodeTable, InputTransform, OutputTransform, REFERENCE_WHITE_NITS, ToneMap}; +pub use transform::{DecodeTable, GpuCurve, GpuInputParams, InputTransform, OutputTransform, REFERENCE_WHITE_NITS, ToneMap}; /// YUV ↔ RGB matrix coefficients. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] diff --git a/crates/color/src/transform.rs b/crates/color/src/transform.rs index 52788d443..99aac66e4 100644 --- a/crates/color/src/transform.rs +++ b/crates/color/src/transform.rs @@ -259,6 +259,54 @@ impl InputTransform { pub fn convert(&self, rgb: [f32; 3]) -> [f32; 3] { self.apply(rgb.map(|v| self.table.lookup(v))) } + + /// The transform as plain numbers for a shader, which evaluates the curve and the BT.2390 + /// EETF directly instead of sampling the tables. `None` for what a shader does not do: log + /// curves and the camera-gamut compression. + pub fn gpu_params(&self) -> Option { + let curve = match self.source.curve() { + Curve::Sdr | Curve::Srgb => GpuCurve::Sdr, + Curve::Linear => GpuCurve::Linear, + Curve::Pq => GpuCurve::Pq, + Curve::Hlg => GpuCurve::Hlg, + Curve::Log(_) => return None, + }; + if self.compress { + return None; + } + Some(GpuInputParams { + curve, + hlg: self.hlg, + matrix: (!self.identity_matrix).then_some(self.matrix), + tone: self.tone.as_ref().map(|t| (t.src_peak_nits as f32, t.dst_peak_nits as f32)), + desaturate: self.desaturate, + }) + } +} + +/// How a shader decodes a channel ([`decode_channel`] without the table): SDR with the sRGB curve, +/// linear, PQ in working units (cd/m² over [`REFERENCE_WHITE_NITS`]), HLG to scene light. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GpuCurve { + Sdr, + Linear, + Pq, + Hlg, +} + +/// [`InputTransform`] for a shader ([`InputTransform::gpu_params`]), stage by stage as +/// [`InputTransform::apply`] runs them. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GpuInputParams { + pub curve: GpuCurve, + /// HLG OOTF: BT.2020 luma weights and the scale to working units. + pub hlg: Option<([f32; 3], f32)>, + /// Into the working primaries (row-major), when they differ. + pub matrix: Option<[[f32; 3]; 3]>, + /// BT.2390 tone mapping (source peak, target peak) in cd/m², on max(R,G,B) as a ratio. + pub tone: Option<(f32, f32)>, + /// Luma weights to desaturate into the working gamut with ([`desaturate_into_gamut`]). + pub desaturate: Option<[f32; 3]>, } /// Whether gamut `a` lies inside gamut `b` (so no compression is needed going a → b). @@ -462,6 +510,25 @@ mod tests { } } + #[test] + fn gpu_params_cover_display_referred_media_only() { + // HLG / PQ / BT.2020 into Rec. 709: every stage the shader has (#965) + let p = |cs| InputTransform::new(cs, Range::Limited, &ColorPipeline::REC709, None).gpu_params(); + let hlg = p(ColorSpace::Rec2100Hlg).unwrap(); + assert_eq!(hlg.curve, GpuCurve::Hlg); + assert!(hlg.hlg.is_some() && hlg.matrix.is_some() && hlg.desaturate.is_some()); + assert_eq!(hlg.tone, Some((HDR_PEAK_NITS as f32, REFERENCE_WHITE_NITS as f32))); + let pq = InputTransform::new(ColorSpace::Rec2100Pq, Range::Limited, &ColorPipeline::REC709, Some(4000.0)).gpu_params().unwrap(); + assert_eq!((pq.curve, pq.hlg, pq.tone), (GpuCurve::Pq, None, Some((4000.0, REFERENCE_WHITE_NITS as f32)))); + let sdr2020 = p(ColorSpace::Rec2020).unwrap(); + assert_eq!((sdr2020.curve, sdr2020.tone), (GpuCurve::Sdr, None)); + assert!(sdr2020.matrix.is_some() && sdr2020.desaturate.is_some()); + // log curves and camera-gamut compression stay on the CPU + for cs in ColorSpace::ALL.into_iter().filter(|c| c.is_log()) { + assert_eq!(p(cs), None, "{cs:?}"); + } + } + #[test] fn sdr_in_hdr_keeps_values_and_display_tone_maps() { let pipe = hdr(WorkingSpace::Rec2100Pq); diff --git a/crates/gpu/src/blend_tests.rs b/crates/gpu/src/blend_tests.rs index 40b575229..a21c5a2b7 100644 --- a/crates/gpu/src/blend_tests.rs +++ b/crates/gpu/src/blend_tests.rs @@ -162,14 +162,15 @@ fn gpu_blend_modes_match_cpu() { layers: vec![ // opaque YUV on the left, a partially transparent ramp on the right; the top-right // corner stays empty (destination alpha 0) - PlanLayer { frame: yuv_frame(640, 360), matrix: Affine::scale(0.35, 0.5), opacity: 1.0, blend: Blend::Normal, fx: None }, - PlanLayer { frame: base.clone(), matrix: Affine::translate(200.0, 40.0), opacity: 0.9, blend: Blend::Normal, fx: None }, + PlanLayer { frame: yuv_frame(640, 360), matrix: Affine::scale(0.35, 0.5), opacity: 1.0, blend: Blend::Normal, fx: None, input: None }, + PlanLayer { frame: base.clone(), matrix: Affine::translate(200.0, 40.0), opacity: 0.9, blend: Blend::Normal, fx: None, input: None }, PlanLayer { frame: top.clone(), matrix: Affine::motion(Vec2::new(170.0, 95.0), Vec2::new(1.1, 1.1), 17.0, Vec2::new(80.0, 60.0)), opacity: 0.8, blend: mode, fx: None, + input: None, }, PlanLayer { frame: demo.clone(), @@ -177,6 +178,7 @@ fn gpu_blend_modes_match_cpu() { opacity: 0.6, blend: mode, fx: None, + input: None, }, ], }; @@ -235,8 +237,8 @@ fn gpu_blend_edge_cases_match_cpu() { width: n, height: n, layers: vec![ - PlanLayer { frame: back.clone(), matrix: Affine::IDENTITY, opacity: 1.0, blend: Blend::Normal, fx: None }, - PlanLayer { frame: src.clone(), matrix: Affine::IDENTITY, opacity, blend: mode, fx: None }, + PlanLayer { frame: back.clone(), matrix: Affine::IDENTITY, opacity: 1.0, blend: Blend::Normal, fx: None, input: None }, + PlanLayer { frame: src.clone(), matrix: Affine::IDENTITY, opacity, blend: mode, fx: None, input: None }, ], }; let cpu = execute_cpu(&plan); @@ -276,8 +278,8 @@ fn gpu_dissolve_pattern_is_exact() { width: w, height: h, layers: vec![ - PlanLayer { frame: red.clone(), matrix: Affine::scale(w as f64, h as f64), opacity: 1.0, blend: Blend::Normal, fx: None }, - PlanLayer { frame: white.clone(), matrix: Affine::scale(w as f64, h as f64), opacity: op, blend: Blend::Dissolve, fx: None }, + PlanLayer { frame: red.clone(), matrix: Affine::scale(w as f64, h as f64), opacity: 1.0, blend: Blend::Normal, fx: None, input: None }, + PlanLayer { frame: white.clone(), matrix: Affine::scale(w as f64, h as f64), opacity: op, blend: Blend::Dissolve, fx: None, input: None }, ], }; let cpu = execute_cpu(&plan).over_black_rgba8(); @@ -303,7 +305,7 @@ fn normal_fast_path_and_blend_layers_off_output() { }; let mut c = GpuCompositor::new(&dev, &q); let grey = Arc::new(VideoFrame::rgba_f32(4, 4, vec![0.2; 64])); - let layer = |m: Affine, blend: Blend| PlanLayer { frame: grey.clone(), matrix: m, opacity: 1.0, blend, fx: None }; + let layer = |m: Affine, blend: Blend| PlanLayer { frame: grey.clone(), matrix: m, opacity: 1.0, blend, fx: None, input: None }; let normal = FramePlan::Layers { width: 32, height: 16, layers: vec![layer(Affine::scale(8.0, 4.0), Blend::Normal)] }; c.composite(&normal); assert!(c.backdrop.is_none()); @@ -329,7 +331,14 @@ fn normal_fast_path_and_blend_layers_off_output() { #[test] fn quad_bounds_clamps_and_handles_non_finite() { - let l = |m: Affine| PlanLayer { frame: Arc::new(VideoFrame::rgba_f32(10, 10, vec![0.0; 400])), matrix: m, opacity: 1.0, blend: Blend::Screen, fx: None }; + let l = |m: Affine| PlanLayer { + frame: Arc::new(VideoFrame::rgba_f32(10, 10, vec![0.0; 400])), + matrix: m, + opacity: 1.0, + blend: Blend::Screen, + fx: None, + input: None, + }; assert_eq!(quad_bounds(&l(Affine::translate(5.0, 5.0)), 100, 100), Some((4, 4, 12, 12))); assert_eq!(quad_bounds(&l(Affine::translate(-5.0, 95.0)), 100, 100), Some((0, 94, 6, 6))); assert_eq!(quad_bounds(&l(Affine::translate(f64::NAN, 0.0)), 100, 100), Some((0, 0, 100, 100))); diff --git a/crates/gpu/src/composite.wgsl b/crates/gpu/src/composite.wgsl index c2eb10440..9e6372d00 100644 --- a/crates/gpu/src/composite.wgsl +++ b/crates/gpu/src/composite.wgsl @@ -15,6 +15,13 @@ struct U { p1: vec4, // y_off y_scale c_off c_scale (code units) p2: vec4, // kr kb code_scale footprint p3: vec4, // blend mode, alpha-plane scale (0: none), effect-source integer decimation, unused + // Colour management of HDR / wide-gamut Y'CbCr (`filmcraft_color::InputTransform`, #965): + q0: vec4, // on, curve (0 sdr, 1 linear, 2 pq, 3 hlg), HLG OOTF scale (0: none), reference white cd/m² + q1: vec4, // HLG OOTF luma weights, gamut matrix on + q2: vec4, // gamut matrix row 0, tone mapping on + q3: vec4, // gamut matrix row 1, tone-mapping source peak cd/m² + q4: vec4, // gamut matrix row 2, tone-mapping target peak cd/m² + q5: vec4, // desaturation luma weights, desaturation on }; @group(0) @binding(0) var u: U; @@ -85,6 +92,82 @@ fn to_linear(v: vec3) -> vec3 { return srgb_to_linear(clamp(v, vec3(0.0), vec3(1.0))); } +// ---- colour management (`filmcraft_color::transform`), for `u.q0.x` = 1 + +// SMPTE ST 2084 on a 0..1 scale (1 = 10 000 cd/m²) and back. +fn pq1(e: f32) -> f32 { + let m1 = 0.1593017578125; let m2 = 78.84375; let c1 = 0.8359375; let c2 = 18.8515625; let c3 = 18.6875; + let p = pow(max(e, 0.0), 1.0 / m2); + return pow(max(p - c1, 0.0) / (c2 - c3 * p), 1.0 / m1); +} +fn pq1_inv(y: f32) -> f32 { + let m1 = 0.1593017578125; let m2 = 78.84375; let c1 = 0.8359375; let c2 = 18.8515625; let c3 = 18.6875; + let p = pow(clamp(y, 0.0, 1.0), m1); + return pow((c1 + c2 * p) / (1.0 + c3 * p), m2); +} + +// ITU-R BT.2390 EETF (`bt2390_eetf`), source peak s to target peak d, in cd/m². +fn eetf(nits: f32, s: f32, d: f32) -> f32 { + if d >= s || nits <= 0.0 { return clamp(nits, 0.0, max(s, d)); } + let smax = pq1_inv(s / 10000.0); + let e1 = min(pq1_inv(nits / 10000.0) / smax, 1.0); + let max_lum = pq1_inv(d / 10000.0) / smax; + let ks = 1.5 * max_lum - 0.5; + var e2 = e1; + if e1 >= ks { + let t = (e1 - ks) / (1.0 - ks); + let t2 = t * t; let t3 = t2 * t; + e2 = (2.0 * t3 - 3.0 * t2 + 1.0) * ks + (t3 - 2.0 * t2 + t) * (1.0 - ks) + (-2.0 * t3 + 3.0 * t2) * max_lum; + } + return pq1(e2 * smax) * 10000.0; +} + +// Signal → working linear: `InputTransform::convert` (curve, HLG OOTF, gamut matrix, tone +// mapping, desaturation into the working gamut). The CPU's decode tables clamp the signal to 0..1. +fn managed(rgb: vec3) -> vec3 { + let v = clamp(rgb, vec3(0.0), vec3(1.0)); + let curve = u32(u.q0.y); + let white = u.q0.w; + var c = v; + if curve == 0u { c = srgb_to_linear(v); } + if curve == 2u { c = vec3(pq1(v.r), pq1(v.g), pq1(v.b)) * (10000.0 / white); } + if curve == 3u { c = hlg_inv(v); } + if u.q0.z > 0.0 { + let ys = max(dot(u.q1.xyz, c), 0.0); + var g = 0.0; + if ys > 0.0 { g = u.q0.z * pow(ys, 0.2); } + c = c * g; + } + if u.q1.w > 0.5 { + c = vec3(dot(u.q2.xyz, c), dot(u.q3.xyz, c), dot(u.q4.xyz, c)); + } + if u.q2.w > 0.5 { + let m = max(max(c.r, c.g), c.b); + if m > 0.0 { + let nits = m * white; + var k = 1.0; + if nits >= u.q3.w { + k = u.q4.w / white / m; + } else if nits > 1e-6 { + k = eetf(nits, u.q3.w, u.q4.w) / nits; + } + c = c * k; + } + } + if u.q5.w > 0.5 { + let mn = min(min(c.r, c.g), c.b); + if mn < 0.0 { + let y = dot(u.q5.xyz, c); + if y <= 0.0 { + c = vec3(0.0); + } else { + c = max(y + (c - y) * (y / (y - mn)), vec3(0.0)); + } + } + } + return c; +} + // One linear premultiplied sample at source position p. fn sample(p: vec2) -> vec4 { let kind = u32(u.p0.y); @@ -113,7 +196,9 @@ fn sample(p: vec2) -> vec4 { if u.p3.y > 0.0 { a = clamp(load4(tex3, p).r * u.p3.y, 0.0, 1.0); } - return vec4(to_linear(vec3(R, G, B)) * a, a); + var lin = to_linear(vec3(R, G, B)); + if u.q0.x > 0.5 { lin = managed(vec3(R, G, B)); } + return vec4(lin * a, a); } // The layer's premultiplied linear colour at this pixel, opacity applied. diff --git a/crates/gpu/src/fx_tests.rs b/crates/gpu/src/fx_tests.rs index 6cd1a1d9b..8a63022e9 100644 --- a/crates/gpu/src/fx_tests.rs +++ b/crates/gpu/src/fx_tests.rs @@ -578,6 +578,7 @@ fn gpu_effect_layers_match_cpu_plan() { opacity: 1.0, blend: Blend::Normal, fx: Some(Arc::new(LayerFx { size: (320, 180), decimation: 2, ops: ops(&chain_c, 320, 180, 0.5) })), + input: None, }, PlanLayer { frame: ramp.clone(), @@ -585,6 +586,7 @@ fn gpu_effect_layers_match_cpu_plan() { opacity: 0.8, blend: mode, fx: Some(Arc::new(LayerFx { size: (160, 120), decimation: 1, ops: ops(&chain_a, 160, 120, 1.0) })), + input: None, }, PlanLayer::new(ramp.clone(), Affine::translate(10.0, 20.0), 0.5, Blend::Normal), PlanLayer { @@ -593,6 +595,7 @@ fn gpu_effect_layers_match_cpu_plan() { opacity: 0.7, blend: mode, fx: Some(Arc::new(LayerFx { size: (320, 180), decimation: 1, ops: ops(&chain_b, 320, 180, 1.0) })), + input: None, }, ], }; diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index d57c53e5b..6daf61bfe 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -586,10 +586,11 @@ impl GpuCompositor { chroma: up.chroma, size: (f.width, f.height), color: f.color, + input: None, }) } - fn uniforms(src: &SrcInfo, m: &filmcraft_geom::Affine, opacity: f32, blend: Blend, out: (u32, u32)) -> [f32; 28] { + fn uniforms(src: &SrcInfo, m: &filmcraft_geom::Affine, opacity: f32, blend: Blend, out: (u32, u32)) -> [f32; UNIFORM_FLOATS] { let (kr, kb) = src.color.matrix.kr_kb(); let bits_scale = src.code_levels / 256.0; // 2^(bits - 8), independent of texture encoding let (yo, ys, co, cs) = match (src.color.range, src.kind) { @@ -609,7 +610,31 @@ impl GpuCompositor { let footprint = (1.0 / sx.min(sy).max(1e-6)) as f32; let taps = if footprint > 1.25 { footprint.ceil().min(4.0) } else { 1.0 }; let _ = Matrix::Bt709; - [ + // colour management (q0..q5), stage by stage as `InputTransform::apply` + let q = src.input.map_or([0.0; 24], |p| { + let curve = match p.curve { + filmcraft_color::GpuCurve::Sdr => 0.0, + filmcraft_color::GpuCurve::Linear => 1.0, + filmcraft_color::GpuCurve::Pq => 2.0, + filmcraft_color::GpuCurve::Hlg => 3.0, + }; + let (hl, hk) = p.hlg.unwrap_or(([0.0; 3], 0.0)); + let mx = p.matrix.unwrap_or([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]); + let (sp, dp) = p.tone.unwrap_or((0.0, 0.0)); + let dl = p.desaturate.unwrap_or([0.0; 3]); + let on = |b: bool| if b { 1.0 } else { 0.0 }; + #[rustfmt::skip] + let q = [ + 1.0, curve, hk, filmcraft_color::REFERENCE_WHITE_NITS as f32, + hl[0], hl[1], hl[2], on(p.matrix.is_some()), + mx[0][0], mx[0][1], mx[0][2], on(p.tone.is_some()), + mx[1][0], mx[1][1], mx[1][2], sp, + mx[2][0], mx[2][1], mx[2][2], dp, + dl[0], dl[1], dl[2], on(p.desaturate.is_some()), + ]; + q + }); + let base = [ m.a as f32, m.b as f32, m.c as f32, @@ -638,10 +663,14 @@ impl GpuCompositor { src.alpha, 0.0, 0.0, - ] + ]; + let mut u = [0.0; UNIFORM_FLOATS]; + u[..28].copy_from_slice(&base); + u[28..].copy_from_slice(&q); + u } - fn uniform_buffer(&self, u: &[f32; 28]) -> wgpu::Buffer { + fn uniform_buffer(&self, u: &[f32; UNIFORM_FLOATS]) -> wgpu::Buffer { let bytes: Vec = u.iter().flat_map(|v| v.to_le_bytes()).collect(); let buf = self.device.create_buffer(&wgpu::BufferDescriptor { label: Some("layer-u"), @@ -679,7 +708,8 @@ impl GpuCompositor { }, None => VideoFrame::rgba_f32(img.w as u32, img.h as u32, img.px.clone()), }; - owned = [PlanLayer { frame: Arc::new(frame), matrix: filmcraft_geom::Affine::IDENTITY, opacity: 1.0, blend: Blend::Normal, fx: None }]; + owned = + [PlanLayer { frame: Arc::new(frame), matrix: filmcraft_geom::Affine::IDENTITY, opacity: 1.0, blend: Blend::Normal, fx: None, input: None }]; (img.w as u32, img.h as u32, &owned[..]) } FramePlan::Composite { width, height, steps } => return self.composite_steps(*width, *height, steps, prep), @@ -736,7 +766,10 @@ impl GpuCompositor { chroma: (1, 1), size: (l.frame.width, l.frame.height), color: l.frame.color, + input: None, }); + // HDR / wide-gamut Y'CbCr: the planner's input transform, in the shader (#965) + let src = SrcInfo { input: l.input.as_ref().filter(|_| src.kind == 2).and_then(|t| t.gpu_params()), ..src }; let views = self.uploads.get(k).map_or_else(|| std::array::from_fn(|_| self.dummy.clone()), |up| up.views.clone()); let job = match &l.fx { Some(lfx) => { @@ -768,7 +801,8 @@ impl GpuCompositor { // what the layer draws: the effect result (linear premultiplied RGBA) or the frame let (u, tex) = match &job { Some(j) => { - let s = SrcInfo { kind: 1, code_scale: 1.0, code_levels: 1.0, alpha: 0.0, chroma: l.size(), size: l.size(), color: l.frame.color }; + let s = + SrcInfo { kind: 1, code_scale: 1.0, code_levels: 1.0, alpha: 0.0, chroma: l.size(), size: l.size(), color: l.frame.color, input: None }; (Self::uniforms(&s, &l.matrix, l.opacity, l.blend, (w, h)), [j.result.clone(), self.dummy.clone(), self.dummy.clone(), self.dummy.clone()]) } None => { @@ -1108,8 +1142,13 @@ struct SrcInfo { chroma: (u32, u32), size: (u32, u32), color: filmcraft_color::ColorInfo, + /// Colour management beyond the default decode (a Y'CbCr layer's [`PlanLayer::input`]). + input: Option, } +/// Floats in a layer's uniform block (`U` in `composite.wgsl`: 13 vec4). +const UNIFORM_FLOATS: usize = 52; + /// A render pass drawing into the accumulator. fn accum_pass<'e>(enc: &'e mut wgpu::CommandEncoder, view: &wgpu::TextureView, load: wgpu::LoadOp) -> wgpu::RenderPass<'e> { enc.begin_render_pass(&wgpu::RenderPassDescriptor { diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index b7807aa63..034333ee3 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -23,6 +23,7 @@ fn yuv_code_range_uniforms() { chroma: (1, 1), size: (1, 1), color: filmcraft_color::ColorInfo { range, ..filmcraft_color::ColorInfo::REC709 }, + input: None, }; let u = GpuCompositor::uniforms(&src, &Affine::IDENTITY, 1.0, Blend::Normal, (1, 1)); // Full: 0..2^bits-1, neutral 2^(bits-1). Limited: BT.709 8-bit codes * 2^(bits-8). @@ -122,13 +123,14 @@ fn gpu_matches_cpu_plan() { width: w, height: h, layers: vec![ - PlanLayer { frame: yuv_frame(640, 360), matrix: Affine::scale(0.5, 0.5), opacity: 1.0, blend: Blend::Normal, fx: None }, + PlanLayer { frame: yuv_frame(640, 360), matrix: Affine::scale(0.5, 0.5), opacity: 1.0, blend: Blend::Normal, fx: None, input: None }, PlanLayer { frame: rgba, matrix: Affine::motion(Vec2::new(200.0, 100.0), Vec2::new(0.4, 0.4), 12.0, Vec2::new(160.0, 90.0)), opacity: 0.7, blend: Blend::Normal, fx: None, + input: None, }, ], }; @@ -287,8 +289,8 @@ fn prepared_upload_matches_inline_conversion() { width: w as usize, height: h as usize, layers: vec![ - PlanLayer { frame: yuv16, matrix: Affine::IDENTITY, opacity: 1.0, blend: Blend::Normal, fx: None }, - PlanLayer { frame: rgbaf, matrix: Affine::scale(0.5, 0.5), opacity: 0.8, blend: Blend::Normal, fx: None }, + PlanLayer { frame: yuv16, matrix: Affine::IDENTITY, opacity: 1.0, blend: Blend::Normal, fx: None, input: None }, + PlanLayer { frame: rgbaf, matrix: Affine::scale(0.5, 0.5), opacity: 0.8, blend: Blend::Normal, fx: None, input: None }, ], }; let image = FramePlan::Image(filmcraft_render::Image { w: w as usize, h: h as usize, px: f32_layer }); @@ -317,8 +319,11 @@ fn upload_cache_keeps_buffers_alive() { let mut c = GpuCompositor::new(&dev, &q); let px = Arc::new(vec![200u8; 16 * 8 * 4]); let frame = Arc::new(VideoFrame { width: 16, height: 8, data: PixelData::Rgba8(px.clone()), ..(*yuv_frame(16, 8)).clone() }); - let plan = - FramePlan::Layers { width: 16, height: 8, layers: vec![PlanLayer { frame, matrix: Affine::IDENTITY, opacity: 1.0, blend: Blend::Normal, fx: None }] }; + let plan = FramePlan::Layers { + width: 16, + height: 8, + layers: vec![PlanLayer { frame, matrix: Affine::IDENTITY, opacity: 1.0, blend: Blend::Normal, fx: None, input: None }], + }; c.composite(&plan); drop(plan); assert!(Arc::strong_count(&px) > 1, "cached upload must own its pixel buffer"); @@ -467,8 +472,8 @@ fn gpu_draws_yuv_alpha_plane_like_the_cpu() { width: w, height: h, layers: vec![ - PlanLayer { frame: background.clone(), matrix: Affine::IDENTITY, opacity: 1.0, blend: Blend::Normal, fx: None }, - PlanLayer { frame: yuv_alpha_frame(320, 180, bits), matrix: Affine::IDENTITY, opacity: 0.9, blend: Blend::Normal, fx: None }, + PlanLayer { frame: background.clone(), matrix: Affine::IDENTITY, opacity: 1.0, blend: Blend::Normal, fx: None, input: None }, + PlanLayer { frame: yuv_alpha_frame(320, 180, bits), matrix: Affine::IDENTITY, opacity: 0.9, blend: Blend::Normal, fx: None, input: None }, ], }; let cpu = execute_cpu(&plan).over_black_rgba8(); @@ -484,7 +489,7 @@ fn gpu_draws_yuv_alpha_plane_like_the_cpu() { let bg = execute_cpu(&FramePlan::Layers { width: w, height: h, - layers: vec![PlanLayer { frame: background.clone(), matrix: Affine::IDENTITY, opacity: 1.0, blend: Blend::Normal, fx: None }], + layers: vec![PlanLayer { frame: background.clone(), matrix: Affine::IDENTITY, opacity: 1.0, blend: Blend::Normal, fx: None, input: None }], }) .over_black_rgba8(); let (l, r, b0) = (at(&gpu, 1), at(&gpu, w - 2), at(&bg, 1)); @@ -500,3 +505,50 @@ fn fast_half_float_conversion_matches_the_reference_for_every_value() { assert!(fast.to_bits() == slow.to_bits() || (fast.is_nan() && slow.is_nan()), "{h:#06x}: {fast} vs {slow}"); } } + +/// HDR and wide-gamut Y'CbCr on a Rec. 709 sequence (#965): the shader's colour management +/// (curve, HLG OOTF, BT.2020 → 709 matrix, BT.2390 tone mapping, desaturation) matches the CPU +/// running the same `InputTransform`. +#[test] +fn hdr_input_gpu_matches_cpu() { + use filmcraft_color::{ColorInfo, ColorPipeline, ColorSpace, InputTransform, Matrix, Primaries, Transfer}; + let Some((dev, q)) = device() else { + eprintln!("no GPU adapter; skipping"); + return; + }; + let mut c = GpuCompositor::new(&dev, &q); + // a 10-bit luma ramp (to the top code) under neutral, saturated and extreme chroma, 4:4:4 + let (w, h) = (256u32, 6u32); + let chroma = [(512, 512), (300, 760), (760, 300), (64, 512), (960, 960), (512, 64)]; + let y: Vec = (0..h).flat_map(|_| (0..w).map(|x| (64 + x * 876 / 255) as u16)).collect(); + let cb: Vec = (0..h).flat_map(|r| std::iter::repeat_n(chroma[r as usize].0, w as usize)).collect(); + let cr: Vec = (0..h).flat_map(|r| std::iter::repeat_n(chroma[r as usize].1, w as usize)).collect(); + let data = PixelData::Yuv16 { planes: [Arc::new(y), Arc::new(cb), Arc::new(cr)], chroma: Chroma::C444, bits: 10, alpha: None }; + let hdr = |transfer| ColorInfo { matrix: Matrix::Bt2020Ncl, transfer, primaries: Primaries::Bt2020, range: Range::Limited }; + let cases = [ + ("HLG", hdr(Transfer::Hlg), None), + ("PQ", hdr(Transfer::Pq), None), + ("PQ 4000 cd/m²", hdr(Transfer::Pq), Some(4000.0)), + ("BT.2020 SDR", ColorInfo { primaries: Primaries::Bt2020, ..ColorInfo::REC709 }, None), + ]; + for (name, color, peak) in cases { + let frame = Arc::new(VideoFrame { width: w, height: h, data: data.clone(), color, par: (1, 1), pts: Default::default() }); + let cs = ColorSpace::from_info(&color); + let t = Arc::new(InputTransform::new(cs, color.range, &ColorPipeline::REC709, peak)); + assert!(t.gpu_params().is_some(), "{name}"); + let layer = PlanLayer { input: Some(t), ..PlanLayer::new(frame.clone(), Affine::IDENTITY, 1.0, Blend::Normal) }; + let plan = FramePlan::Layers { width: w as usize, height: h as usize, layers: vec![layer] }; + let cpu = execute_cpu(&plan).over_black_rgba8(); + c.composite(&plan); + let (_, _, gpu) = c.read_output().expect("readback"); + let error = cpu.iter().zip(&gpu).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0); + // and the stage does something: the default decode of the same frame is far off + let plain = FramePlan::Layers { width: w as usize, height: h as usize, layers: vec![PlanLayer::new(frame, Affine::IDENTITY, 1.0, Blend::Normal)] }; + c.composite(&plain); + let (_, _, unmanaged) = c.read_output().expect("readback"); + let apart = cpu.iter().zip(&unmanaged).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0); + eprintln!("{name}: max error {error}, unmanaged differs by up to {apart}"); + assert!(error <= 2, "{name}: GPU differs from the CPU by up to {error} codes"); + assert!(apart > 20, "{name}: colour management changed nothing ({apart})"); + } +} diff --git a/crates/render/src/colorman.rs b/crates/render/src/colorman.rs index 06a7afea1..e645b9c3a 100644 --- a/crates/render/src/colorman.rs +++ b/crates/render/src/colorman.rs @@ -3,7 +3,10 @@ //! transform. The maths lives in `filmcraft_color::transform`. //! //! A sequence whose pipeline is "plain" (Rec. 709 working space without wide gamut) and media -//! whose metadata says Rec. 709 / sRGB / linear take the original fast path unchanged. +//! whose metadata says Rec. 709 / sRGB / linear take the original fast path unchanged. On such a +//! sequence, display-referred HDR and wide-gamut Y'CbCr (PQ, HLG, BT.2020, P3) stays on the GPU +//! path as well, with its input transform run in the compositor's shader ([`gpu_input`]); log +//! media and the other pipelines are converted here, on the CPU. use std::collections::HashMap; use std::sync::{Arc, Mutex, OnceLock}; @@ -51,6 +54,18 @@ pub fn input_transform(cs: ColorSpace, range: Range, pipe: &ColorPipeline, peak_ t } +/// The input transform of a frame the GPU compositor can colour manage itself (#965): a Y'CbCr +/// frame of display-referred HDR or wide-gamut media (PQ, HLG, BT.2020, P3) on a plain pipeline, +/// whose working → monitor transform is the identity. None for log media (camera-gamut +/// compression), RGB frames and other pipelines, which [`decode`] converts on the CPU. +pub fn gpu_input(project: &Project, item: ItemId, pipe: &ColorPipeline, cs: ColorSpace, frame: &VideoFrame) -> Option> { + if !pipe.is_plain() || !matches!(frame.data, filmcraft_frame::PixelData::Yuv8 { .. } | filmcraft_frame::PixelData::Yuv16 { .. }) { + return None; + } + let t = input_transform(cs, frame.color.range, pipe, source_peak_nits(project, item)); + t.gpu_params().map(|_| t) +} + /// Decode a frame of `item` (box-decimated by `n`) into the working space of `pipe`. pub fn decode(project: &Project, item: ItemId, frame: &VideoFrame, n: usize, pipe: &ColorPipeline) -> Image { let cs = source_space(project, item, frame); diff --git a/crates/render/src/plan.rs b/crates/render/src/plan.rs index 4dd0d4d0e..166b615bc 100644 --- a/crates/render/src/plan.rs +++ b/crates/render/src/plan.rs @@ -39,6 +39,10 @@ pub struct PlanLayer { pub blend: Blend, /// Standard effects to run on the frame before it is placed (None: draw the frame directly). pub fx: Option>, + /// Colour management of a Y'CbCr frame that needs more than the default decode (HDR, BT.2020, + /// P3 media on a Rec. 709 sequence; [`crate::colorman::gpu_input`]): run on the GPU as on the + /// CPU. None: the default decode. + pub input: Option>, } /// The GPU effect stage of a layer: the frame is decoded into a working image of `size` (the @@ -54,7 +58,7 @@ pub struct LayerFx { impl PlanLayer { /// A layer drawing `frame` directly. pub fn new(frame: Arc, matrix: Affine, opacity: f32, blend: Blend) -> Self { - Self { frame, matrix, opacity, blend, fx: None } + Self { frame, matrix, opacity, blend, fx: None, input: None } } /// Size of the picture the matrix places: the working image with effects, else the frame. @@ -408,10 +412,13 @@ fn push_item( let time = crate::video_source_time(item, t, src.info().frame_rate()); let Ok(frame) = src.video_frame(FrameRequest { time, scale: want }) else { return }; let cs = crate::colorman::source_space(project, item.item, &frame); - // log / HDR / wide-gamut media is converted on the CPU (below), and so are blended + // HDR and wide-gamut media on a Rec. 709 sequence is converted on the GPU too (#965); log + // media and other pipelines are converted on the CPU (below), and so are blended // in-between frames (Frame Blending / Optical Flow on speed-changed clips) + let pipe = &seq.settings.color; + let input = if crate::colorman::needs_management(pipe, cs, &frame) { crate::colorman::gpu_input(project, item.item, pipe, cs, &frame) } else { None }; let plain = - !crate::colorman::needs_management(&seq.settings.color, cs, &frame) && crate::interpolation_blend(item, t, src.info().frame_rate()).is_none(); + (input.is_some() || !crate::colorman::needs_management(pipe, cs, &frame)) && crate::interpolation_blend(item, t, src.info().frame_rate()).is_none(); if plain && !chain.is_empty() { // GPU effect stage: the working image the CPU would decode (`base_layer`), the // effects evaluated for it, placed as `item_layer` places it @@ -435,7 +442,7 @@ fn push_item( .then_apply(&motion) .then_apply(&Affine::scale(1.0 / px_scale as f64, 1.0 / px_scale as f64)); let fx = LayerFx { size: (lw as u32, lh as u32), decimation: n as u32, ops }; - out.push(PlanLayer { frame, matrix: m, opacity: op * extra_opacity, blend: bl, fx: Some(Arc::new(fx)) }); + out.push(PlanLayer { frame, matrix: m, opacity: op * extra_opacity, blend: bl, fx: Some(Arc::new(fx)), input }); return; } } else if plain { @@ -450,7 +457,7 @@ fn push_item( }; let px_scale = frame.width as f64 / size.0.max(1) as f64; let m = Affine::scale(opts.scale as f64, opts.scale as f64).then_apply(&motion).then_apply(&Affine::scale(1.0 / px_scale, 1.0 / px_scale)); - out.push(PlanLayer::new(frame, m, op * extra_opacity, bl)); + out.push(PlanLayer { input, ..PlanLayer::new(frame, m, op * extra_opacity, bl) }); return; } } @@ -468,8 +475,12 @@ fn near_identity(m: &Affine) -> bool { /// A layer's working image with its effects applied, on the CPU (the reference for the GPU /// effect stage). pub fn effect_image(frame: &VideoFrame, fx: &LayerFx) -> crate::Image { - let (w, h, px) = frame.to_linear_f32_decimated(fx.decimation.max(1) as usize); - let mut img = crate::Image { w, h, px }; + effect_image_with(frame, fx, None) +} + +/// [`effect_image`] of a colour-managed layer ([`PlanLayer::input`]). +pub fn effect_image_with(frame: &VideoFrame, fx: &LayerFx, input: Option<&filmcraft_color::InputTransform>) -> crate::Image { + let mut img = linear_image(frame, fx.decimation.max(1) as usize, input); for op in &fx.ops { op.apply(&mut img); } @@ -506,12 +517,23 @@ pub fn execute_cpu(plan: &FramePlan) -> crate::Image { } } +/// A layer's frame (box-decimated by `n`) as a linear working image: the default decode, or +/// `input`'s table and pixel stage as `colorman::decode` runs them. +fn linear_image(frame: &VideoFrame, n: usize, input: Option<&filmcraft_color::InputTransform>) -> crate::Image { + let (w, h, px) = frame.to_linear_f32_decimated_with(n, input.map(|t| &t.table)); + let mut img = crate::Image { w, h, px }; + if let Some(t) = input.filter(|t| t.has_pixel_stage()) { + img.map_rgb(|c, _, _| t.apply(c)); + } + img +} + fn execute_layers(canvas: &mut crate::Image, layers: &[PlanLayer]) { let (width, height) = (canvas.w, canvas.h); for l in layers { let src = match &l.fx { - Some(fx) => effect_image(&l.frame, fx), - None => crate::Image { w: l.frame.width as usize, h: l.frame.height as usize, px: l.frame.to_linear_f32() }, + Some(fx) => effect_image_with(&l.frame, fx, l.input.as_deref()), + None => linear_image(&l.frame, 1, l.input.as_deref()), }; let placed = if l.matrix == Affine::scale(width as f64, height as f64) && src.w == 1 && src.h == 1 { crate::Image::filled(width, height, src.get(0, 0)) diff --git a/crates/render/src/region_tests.rs b/crates/render/src/region_tests.rs index 822fa5518..fd0e7f9bf 100644 --- a/crates/render/src/region_tests.rs +++ b/crates/render/src/region_tests.rs @@ -299,3 +299,28 @@ fn a_region_layer_expands_to_the_whole_layer() { assert_eq!(full.px.iter().filter(|v| **v == 1.0).count(), 2 * 2 * 4); assert!(full.px[..(2 * 6 + 4) * 4].iter().all(|v| *v == 0.0)); } + +/// HDR and wide-gamut Y'CbCr on a Rec. 709 sequence is planned as a GPU layer carrying its input +/// transform, not converted to a float image on the CPU (#965: 4K HLG / PQ playback fell to a +/// third of real time), and the plan still renders what the reference renderer does. +#[test] +fn hdr_media_is_planned_for_the_gpu_with_its_colour_management() { + use filmcraft_color::{ColorInfo, Matrix, Primaries, Range, Transfer}; + let hdr = |transfer| ColorInfo { matrix: Matrix::Bt2020Ncl, transfer, primaries: Primaries::Bt2020, range: Range::Limited }; + for (name, color) in + [("HLG", hdr(Transfer::Hlg)), ("PQ", hdr(Transfer::Pq)), ("BT.2020 SDR", ColorInfo { primaries: Primaries::Bt2020, ..ColorInfo::REC709 })] + { + let f = Arc::new(VideoFrame { color, ..(*camera(W, H)).clone() }); + let (p, seq, map) = scene(&[Spec::new(f)]); + let opts = RenderOptions::default(); + let plan = plan::plan_frame(&p, seq, Tick::ZERO, opts, &map); + let plan::FramePlan::Layers { layers, .. } = &plan else { panic!("{name}: not a layer plan") }; + assert_eq!(layers.len(), 1, "{name}"); + assert!(matches!(layers[0].frame.data, PixelData::Yuv8 { .. }), "{name}: converted on the CPU"); + assert!(layers[0].input.is_some(), "{name}: no colour management for the GPU"); + let reference = render_sequence(&p, seq, Tick::ZERO, opts, &map); + let got = plan::execute_cpu(&plan); + let max = got.px.iter().zip(&reference.px).map(|(a, b)| (a - b).abs()).fold(0f32, f32::max); + assert!(max < 1e-4, "{name}: plan differs from the reference by {max}"); + } +} diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index c8db19cdd..65db9d57f 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -914,7 +914,7 @@ fn plan_job(job: &Job, seq: ItemId, pool: &Arc, services: &Arc, services: &Arc Date: Sat, 10 Oct 2026 11:29:33 +0300 Subject: [PATCH 214/260] Escape cancels a drag-and-drop in progress (#580) egui ends a drag when Escape is pressed, and FilmCraft treated that stop as a release: the drag was committed wherever the pointer was. Escape now abandons it: nothing moves, nothing is dropped, nothing goes on the undo stack. - Timeline: clip moves, trims, rolls, slips, slides and stretches, caption blocks and automation keyframes. The clips stay put and stay selected. - Items carried between panels (a clip or sequence from the Project panel, Source monitor drags, effects, presets, graphics templates): the payload is cleared in drag_ghost. panels::cancel_drag / drag_cancelled keep a cancelled drag from restarting until the button comes up. - Freeform card moves go back, and the next drag works. - Media Browser drags, which import on drag start, take the import back even when released over a panel. Tests: escape_drag_ui (5 tests: Timeline clip, caption, Project panel item, Freeform card, effect; each with the same drag without Escape as a control) and escape_takes_back_a_media_browser_drag. Automation keyframes have no test fixture. The ui-egui suite passes except the two Windows CRLF failures fixed in #488. --- crates/ui-egui/src/panels/media_browser.rs | 5 +- crates/ui-egui/src/panels/mod.rs | 25 ++- crates/ui-egui/src/panels/project_views.rs | 10 +- crates/ui-egui/src/panels/timeline.rs | 5 + .../ui-egui/src/panels/timeline_automation.rs | 9 +- .../ui-egui/src/panels/timeline_captions.rs | 3 +- crates/ui-egui/tests/escape_drag_ui.rs | 176 ++++++++++++++++++ crates/ui-egui/tests/project_panel_ui.rs | 36 ++++ 8 files changed, 260 insertions(+), 9 deletions(-) create mode 100644 crates/ui-egui/tests/escape_drag_ui.rs diff --git a/crates/ui-egui/src/panels/media_browser.rs b/crates/ui-egui/src/panels/media_browser.rs index c19ed5ce2..592abb20e 100644 --- a/crates/ui-egui/src/panels/media_browser.rs +++ b/crates/ui-egui/src/panels/media_browser.rs @@ -470,8 +470,9 @@ fn finish_drag(app: &mut FilmcraftApp, ui: &egui::Ui) { } let pos = with_cache(ui.ctx(), |c| c.drag.take().map(|d| d.2)).unwrap_or_default(); let targets = ["panel.Project", "panel.Timeline", "panel.Source", "panel.Program"]; - let landed = - targets.iter().any(|t| app.auto.find(t).is_some_and(|e| Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3])).contains(pos))); + // a drag cancelled with Escape (#580) landed nowhere, wherever the pointer is + let landed = !crate::panels::drag_cancelled(ui) + && targets.iter().any(|t| app.auto.find(t).is_some_and(|e| Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3])).contains(pos))); let used_elsewhere = app.session.history.undo.len() > hist; if !landed && !used_elsewhere && items.iter().all(|i| app.session.project.item(ItemId(*i)).is_some()) { // dropped nowhere: take the import back diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 5b1b9f51c..768a9474a 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -188,10 +188,33 @@ pub fn clear_drag(ui: &egui::Ui) { ui.ctx().data_mut(|d| d.insert_temp::>(payload_id(), None)); } -/// Draw the drag ghost near the pointer and clear the payload after release. +fn cancelled_id() -> egui::Id { + egui::Id::new("filmcraft-drag-cancelled") +} + +/// Abandon the drag in progress (Escape, #580): nothing is dropped, and nothing restarts it until +/// the button comes up. +pub fn cancel_drag(ui: &egui::Ui) { + clear_drag(ui); + ui.ctx().data_mut(|d| d.insert_temp(cancelled_id(), true)); +} + +/// Whether the press in progress had its drag cancelled with Escape. +pub fn drag_cancelled(ui: &egui::Ui) -> bool { + ui.ctx().data(|d| d.get_temp::(cancelled_id())).unwrap_or(false) +} + +/// Draw the drag ghost near the pointer and clear the payload after release. Escape cancels it. pub fn drag_ghost(app: &FilmcraftApp, ui: &egui::Ui) { + if !ui.ctx().input(|i| i.pointer.any_down()) { + ui.ctx().data_mut(|d| d.remove::(cancelled_id())); + } let Some(pl) = payload(ui) else { return }; let ctx = ui.ctx(); + if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + cancel_drag(ui); + return; + } 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(), diff --git a/crates/ui-egui/src/panels/project_views.rs b/crates/ui-egui/src/panels/project_views.rs index e48d98dd4..8e47a487b 100644 --- a/crates/ui-egui/src/panels/project_views.rs +++ b/crates/ui-egui/src/panels/project_views.rs @@ -1017,7 +1017,13 @@ pub fn freeform_view(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: & app.auto.add(&format!("{}.stack.{s}", v.prefix), br, &format!("Stack of {n}")); } item_interactions(app, ui, &resp, ItemId(c.item), &kind, actions, false, false); - if resp.dragged() { + // Escape puts the cards back and drops nothing until the button comes up (#580); egui + // also ends the drag on Escape, so it arrives as a stop as often as during the drag + let escape = ui.input(|i| i.key_pressed(egui::Key::Escape)); + if (resp.dragged() || resp.drag_stopped()) && (escape || crate::panels::drag_cancelled(ui)) { + ui.ctx().data_mut(|d| d.remove::<(u64, egui::Vec2)>(drag_key)); + crate::panels::cancel_drag(ui); + } else if resp.dragged() { let total = dragging.filter(|d| d.0 == c.item).map(|d| d.1).unwrap_or_default() + resp.drag_delta(); ui.ctx().data_mut(|d| d.insert_temp(drag_key, (c.item, total))); // leaving the panel turns the move into an item drag (to the Timeline, a monitor) @@ -1028,7 +1034,7 @@ pub fn freeform_view(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: & crate::panels::start_drag_item(ui, ItemId(c.item)); } } - if resp.drag_stopped() { + if resp.drag_stopped() && !crate::panels::drag_cancelled(ui) { released = dragging.filter(|d| d.0 == c.item).map(|d| (d.0, d.1 + resp.drag_delta())); } resp.context_menu(|ui| card_menu(app, ui, v, c.item, actions)); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index d5766c0bb..112740e83 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2200,6 +2200,11 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & } } + // ---- Escape abandons the drag: the release commits nothing and the clips stay put (#580) + if app.tl.drag.is_some() && ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + app.tl.drag = None; + } + // ---- drag if resp.dragged() && let Some(p) = resp.interact_pointer_pos() diff --git a/crates/ui-egui/src/panels/timeline_automation.rs b/crates/ui-egui/src/panels/timeline_automation.rs index 783dc5ba4..4e4ecc086 100644 --- a/crates/ui-egui/src/panels/timeline_automation.rs +++ b/crates/ui-egui/src/panels/timeline_automation.rs @@ -211,9 +211,12 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & if resp.drag_stopped() && let Some(d) = dragging { - let nt = layout.tick_at(c.x).max(Tick::ZERO); - let nv = denorm(tr, &key, ((b.max.y - c.y) / b.height()).clamp(0.0, 1.0)); - acts.push(("mixer.moveKeyframe", json!({"strip": r.track.0, "lane": key, "time": d.orig.0, "newTime": nt.0, "value": nv}))); + // egui ends a drag on Escape: that stop abandons it (#580) + if !ui.input(|i| i.key_pressed(egui::Key::Escape)) { + let nt = layout.tick_at(c.x).max(Tick::ZERO); + let nv = denorm(tr, &key, ((b.max.y - c.y) / b.height()).clamp(0.0, 1.0)); + acts.push(("mixer.moveKeyframe", json!({"strip": r.track.0, "lane": key, "time": d.orig.0, "newTime": nt.0, "value": nv}))); + } ui.data_mut(|dd| dd.remove::(drag_id())); } if dragging.is_some() { diff --git a/crates/ui-egui/src/panels/timeline_captions.rs b/crates/ui-egui/src/panels/timeline_captions.rs index 781c7ade5..fd123576d 100644 --- a/crates/ui-egui/src/panels/timeline_captions.rs +++ b/crates/ui-egui/src/panels/timeline_captions.rs @@ -259,7 +259,8 @@ fn interact_block( && id == c.id.0 { ctx.data_mut(|dd| dd.remove::<(u64, u8, i64)>(drag_id())); - if d != 0 { + // egui ends a drag on Escape: that stop abandons it (#580) + if d != 0 && !ctx.input(|i| i.key_pressed(egui::Key::Escape)) { match g { 0 => { let sel: Vec = if app.session.state.caption_selection.contains(&ClipId(id)) { diff --git a/crates/ui-egui/tests/escape_drag_ui.rs b/crates/ui-egui/tests/escape_drag_ui.rs new file mode 100644 index 000000000..51c74b6f5 --- /dev/null +++ b/crates/ui-egui/tests/escape_drag_ui.rs @@ -0,0 +1,176 @@ +//! Escape during a drag-and-drop abandons it (#580): moving clips and captions on the Timeline, +//! dragging an item out of the Project panel, moving a Freeform card and dragging an effect onto a +//! clip. Each test +//! also makes the same drag without Escape, so it shows the drag would otherwise have done something. + +use std::sync::mpsc::{Sender, channel}; + +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_engine::project_panel::FREEFORM_POS; +use filmcraft_project::{BinEntry, ItemId}; +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(r) = reply.try_recv() { + assert_eq!(r["ok"], true, "{method} {params}: {r}"); + self.frames(2); + return r["result"].clone(); + } + } + panic!("no reply to {method} {params}"); + } + fn rect(&mut self, id: &str) -> [f32; 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() as f32).collect(); + [r[0], r[1], r[2], r[3]] + } + fn send(&mut self, e: egui::Event) { + self.harness.input_mut().events.push(e); + self.frames(1); + } + /// A left-button drag in 12 steps, with Escape pressed half way when `escape`. + fn drag(&mut self, from: egui::Pos2, to: egui::Pos2, escape: bool) { + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + self.send(egui::Event::PointerMoved(from)); + self.send(button(from, true)); + for k in 1..=12 { + self.send(egui::Event::PointerMoved(from.lerp(to, k as f32 / 12.0))); + if escape && k == 6 { + self.send(egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }); + } + } + self.send(button(to, false)); + self.frames(4); + } + fn project(&self) -> String { + self.harness.state().session.project.to_json().to_string() + } + fn undo_len(&self) -> usize { + self.harness.state().session.history.undo.len() + } + /// The demo's Footage bin open in place in the Project panel in `view`; its items. + fn footage_in(&mut self, view: &str) -> Vec { + let p = &self.harness.state().session.project; + let bin = p.root.children.iter().find_map(|e| if let BinEntry::Bin(b) = e { (b.name == "Footage").then(|| b.clone()) } else { None }).expect("Footage"); + let items: Vec = bin.children.iter().filter_map(|e| if let BinEntry::Item(i) = e { Some(i.0) } else { None }).collect(); + self.ok("ui.panel.show", json!({"panel": "Project"})); + self.ok("engine.execute", json!({"command": "project.view.set", "params": {"view": view}})); + self.ok("ui.menu.invoke", json!({"id": "projectPanel.openBin", "params": {"bin": bin.id.0, "how": "inPlace"}})); + self.frames(4); + items + } +} + +fn centre(r: [f32; 4]) -> egui::Pos2 { + egui::pos2(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0) +} + +#[test] +fn escape_puts_a_moved_timeline_clip_back() { + let mut d = Driver::demo(); + let clip = d.harness.state().session.active_sequence().unwrap().video_tracks[0].items[0].id; + d.ok("engine.execute", json!({"command": "timeline.select", "params": {"clips": [clip.0]}})); + let (before, undo) = (d.project(), d.undo_len()); + let from = centre(d.rect(&format!("timeline.clip.{}", clip.0))); + let to = from + egui::vec2(120.0, 0.0); + d.drag(from, to, true); + assert_eq!(d.project(), before, "the clip stayed where it was"); + assert_eq!(d.undo_len(), undo, "nothing to undo"); + assert!(d.harness.state().session.state.selection.contains(&clip), "the clip is still selected (with its linked audio)"); + d.drag(from, to, false); + assert_ne!(d.project(), before, "without Escape the same drag moves the clip"); +} + +#[test] +fn escape_puts_a_moved_caption_back() { + let mut d = Driver::demo(); + let id = + d.ok("engine.execute", json!({"command": "captions.add", "params": {"seconds": 2.0, "durationSeconds": 2.0}}))["caption"].as_u64().expect("caption id"); + d.frames(4); + let from = centre(d.rect(&format!("timeline.caption.{id}"))); + let to = from + egui::vec2(60.0, 0.0); + let (before, undo) = (d.project(), d.undo_len()); + d.drag(from, to, true); + assert_eq!(d.project(), before, "the caption stayed where it was"); + assert_eq!(d.undo_len(), undo); + d.drag(from, to, false); + assert_ne!(d.project(), before, "without Escape the same drag moves the caption"); +} + +#[test] +fn escape_drops_nothing_from_the_project_panel() { + let mut d = Driver::demo(); + let items = d.footage_in("list"); + let from = centre(d.rect(&format!("project.item.{}", items[0]))); + let tl = d.rect("panel.Timeline"); + let to = egui::pos2(tl[0] + tl[2] * 0.6, tl[1] + tl[3] * 0.75); + let (before, undo) = (d.project(), d.undo_len()); + d.drag(from, to, true); + assert_eq!(d.project(), before, "nothing was placed on the Timeline"); + assert_eq!(d.undo_len(), undo); + d.drag(from, to, false); + assert_ne!(d.project(), before, "without Escape the same drag places the clip"); +} + +#[test] +fn escape_puts_a_freeform_card_back_and_the_next_drag_works() { + let mut d = Driver::demo(); + let items = d.footage_in("freeform"); + let from = centre(d.rect(&format!("project.item.{}", items[0]))); + let to = from + egui::vec2(90.0, 60.0); + d.drag(from, to, true); + let meta = |d: &Driver| d.harness.state().session.project.item(ItemId(items[0])).unwrap().metadata.contains_key(FREEFORM_POS); + assert!(!meta(&d), "the card was not placed"); + d.drag(from, to, false); + assert!(meta(&d), "after a cancel the next drag places the card"); +} + +#[test] +fn escape_cancels_dragging_an_effect_onto_a_clip() { + let mut d = Driver::demo(); + d.ok("ui.panel.show", json!({"panel": "Effects"})); + let effects = d.ok("ui.elements", json!({"prefix": "effects.item."})); + let id = effects.as_array().unwrap().iter().filter_map(|e| e["id"].as_str()).find(|i| i.matches('.').count() == 2).expect("an effect").to_string(); + let from = centre(d.rect(&id)); + let clip = d.harness.state().session.active_sequence().unwrap().video_tracks[0].items[0].id; + let to = centre(d.rect(&format!("timeline.clip.{}", clip.0))); + let (before, undo) = (d.project(), d.undo_len()); + d.drag(from, to, true); + assert_eq!(d.project(), before, "no effect was applied"); + assert_eq!(d.undo_len(), undo); + d.drag(from, to, false); + assert_ne!(d.project(), before, "without Escape the same drag applies {id}"); +} diff --git a/crates/ui-egui/tests/project_panel_ui.rs b/crates/ui-egui/tests/project_panel_ui.rs index 191875082..3f4142284 100644 --- a/crates/ui-egui/tests/project_panel_ui.rs +++ b/crates/ui-egui/tests/project_panel_ui.rs @@ -1002,3 +1002,39 @@ fn marquee_selects_cards_in_freeform_view() { 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"); } + +/// Escape during a Media Browser drag (#580) takes the import back, even over the Timeline. +#[test] +fn escape_takes_back_a_media_browser_drag() { + let dir = media_dir("drag-escape"); + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).unwrap(); + let mut d = Driver::with(session); + d.ok("ui.set", json!({"workspace": "Editing"})); + d.ok("ui.panel.show", json!({"panel": "MediaBrowser"})); + d.exec("mediaBrowser.navigate", json!({"path": dir.to_string_lossy()})); + d.frames(3); + d.wait_for("mediaBrowser.entry.b-roll.wav"); + let (n0, before) = (d.app().session.project.items.len(), d.app().session.project.to_json()); + let e = d.rect("mediaBrowser.entry.b-roll.wav"); + let tl = d.rect("panel.Timeline"); + let (from, to) = (egui::pos2(e[0] + 40.0, e[1] + e[3] / 2.0), egui::pos2(tl[0] + tl[2] * 0.6, tl[1] + tl[3] * 0.75)); + let mut send = |e: egui::Event| { + d.harness.input_mut().events.push(e); + d.frames(1); + }; + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + send(egui::Event::PointerMoved(from)); + send(button(from, true)); + for k in 1..=20 { + send(egui::Event::PointerMoved(from.lerp(to, k as f32 / 20.0))); + if k == 10 { + send(egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }); + } + } + send(button(to, false)); + d.frames(6); + assert_eq!(d.app().session.project.items.len(), n0, "the import was taken back"); + assert_eq!(d.app().session.project.to_json(), before, "nothing was placed on the Timeline"); + let _ = std::fs::remove_dir_all(&dir); +} From 6b7f48d04dc7a53d21017f63c5d5d463724485a2 Mon Sep 17 00:00:00 2001 From: ReReddo <249398616+ReReddo@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:37:53 +0300 Subject: [PATCH 215/260] Escape cancels a value drag, color pick or dialog drag in progress (#580) The same rule as for drag-and-drop: Escape while the mouse button is held takes the drag back and leaves everything as it was before the press. - Value drags in panels (hot numbers, sliders and color pickers in Effect Controls, Properties, Lumetri and elsewhere): the engine gets Session::undo_mark / revert_to, and the app marks the history when a press begins and reverts to the mark on Escape. revert_to drops the steps without adding them to redo. - Lumetri made one undo step per frame of a drag; it now goes through effect_controls::run and merges them into one, as Effect Controls does (Properties does since #522). - Drags that commit on release (keyframes, influence handles, graph keys, trim monitor, caption size, audio FX editor, monitor guides) skip the commit when Escape ends them. UI-only sliders (Project and Media Browser thumbnail size, Export settings) go back to their value at the press; a sequence tab dragged out of the dock goes back home. - Dialogs: Escape with a button held no longer closes the dialog (widgets::escape_closes, also for the modal's own close). Dialogs with a draft keep it at the press and put it back on Escape (widgets::revert_drag_on_escape), color pickers included. Tests: reverting_to_a_mark_takes_back_a_gesture_without_redo, reverting_to_a_lost_mark_does_nothing; escape_drag_ui gains a Properties value drag, the thumbnail-size slider and the Properties color picker; clip_dialogs_ui and settings_ui check that a dialog field and a Settings color go back while the dialog stays open. --- crates/engine/src/lib.rs | 34 +++++++ crates/engine/src/tests.rs | 44 +++++++++ crates/ui-egui/src/dock.rs | 13 +++ crates/ui-egui/src/dock_ui.rs | 1 + crates/ui-egui/src/lib.rs | 28 ++++++ crates/ui-egui/src/panels/audio_fx_editor.rs | 31 ++++--- crates/ui-egui/src/panels/clip_dialogs.rs | 3 +- crates/ui-egui/src/panels/dialogs.rs | 10 ++- crates/ui-egui/src/panels/effect_controls.rs | 25 ++++-- crates/ui-egui/src/panels/export_mode.rs | 17 +++- crates/ui-egui/src/panels/file_dialogs.rs | 3 +- crates/ui-egui/src/panels/frame_export.rs | 2 +- crates/ui-egui/src/panels/graphics.rs | 5 +- .../ui-egui/src/panels/interchange_export.rs | 3 +- crates/ui-egui/src/panels/lumetri.rs | 7 +- crates/ui-egui/src/panels/media_browser.rs | 16 +++- crates/ui-egui/src/panels/media_dialogs.rs | 9 +- crates/ui-egui/src/panels/menu_dialogs.rs | 3 +- crates/ui-egui/src/panels/mod.rs | 2 +- crates/ui-egui/src/panels/monitor_view.rs | 20 ++++- crates/ui-egui/src/panels/multicam.rs | 5 +- crates/ui-egui/src/panels/project.rs | 14 ++- crates/ui-egui/src/panels/project_dialogs.rs | 3 +- crates/ui-egui/src/panels/remix.rs | 3 +- .../ui-egui/src/panels/sequence_settings.rs | 3 +- crates/ui-egui/src/panels/settings.rs | 4 +- crates/ui-egui/src/panels/text.rs | 4 +- crates/ui-egui/src/panels/trim_monitor.rs | 3 +- crates/ui-egui/src/panels/voiceover.rs | 3 +- crates/ui-egui/src/panels/workspaces.rs | 2 +- crates/ui-egui/src/widgets.rs | 29 ++++++ crates/ui-egui/tests/clip_dialogs_ui.rs | 44 +++++++++ crates/ui-egui/tests/escape_drag_ui.rs | 75 ++++++++++++++++ crates/ui-egui/tests/settings_ui.rs | 90 +++++++++++++++++++ 34 files changed, 501 insertions(+), 57 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a2ed9e6a2..1d53fe201 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -192,6 +192,10 @@ impl Services for FsServices { } } +/// A point in the undo history (its top step), from [`Session::undo_mark`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct UndoMark(Option); + /// Undo history of whole-project snapshots. #[derive(Clone, Default)] pub struct History { @@ -833,6 +837,36 @@ impl Session { Ok(r) } + /// Where the undo history stands now, for [`Session::revert_to`]. + pub fn undo_mark(&self) -> UndoMark { + UndoMark(self.history.undo.last().map(|u| Arc::as_ptr(&u.1) as usize)) + } + + /// Take back every edit made since `mark` (a gesture cancelled with Escape, #580), merged or + /// not: the project returns to how it was at `mark`, and neither undo nor redo keeps them. + /// Nothing happens when `mark` has left the history. Returns how many steps were taken back. + pub fn revert_to(&mut self, mark: UndoMark) -> usize { + if let UndoMark(Some(p)) = mark + && !self.history.undo.iter().any(|u| Arc::as_ptr(&u.1) as usize == p) + { + return 0; + } + let mut earliest = None; + let mut n = 0; + while self.undo_mark() != mark { + let Some((_, prev)) = self.history.undo.pop() else { break }; + earliest = Some(prev); + n += 1; + } + if let Some(prev) = earliest { + self.project = prev; + self.history.merge_key = None; + self.fix_state(); + self.bump(); + } + n + } + /// Edit the active sequence with the edit-algebra context. pub fn edit_sequence(&mut self, label: &str, f: impl FnOnce(&mut Sequence, &mut EditCtx, &mut EditorState) -> Result) -> Result { self.edit_sequence_as(label, None, f) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9852734d7..e3905d6b2 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -958,3 +958,47 @@ fn enable_flips_each_selected_clip() { s.execute("clip.enable", json!({"clips": [a, b]})).unwrap(); assert_eq!((enabled(&s, a), enabled(&s, b)), (true, true)); } + +/// #580: Escape takes back what a gesture changed, merged or not: the project returns to the mark, +/// neither undo nor redo keeps the changes, and older steps stay. +#[test] +fn reverting_to_a_mark_takes_back_a_gesture_without_redo() { + let mut s = demo_unlocked(); + let id = v1(&s)[0].0; + let opacity = |s: &Session| { + let q = s.active_sequence().unwrap(); + let it = q.find_item(ClipId(id)).unwrap().1; + it.effects.iter().find(|e| e.effect == "opacity").unwrap().params["opacity"].value.as_f64().unwrap() + }; + let set = |s: &mut Session, v: f64, merge: bool, begin: bool| { + s.execute("effects.setParam", json!({"clip": id, "effect": "opacity", "param": "opacity", "value": v, "merge": merge, "begin": begin})).unwrap(); + }; + set(&mut s, 80.0, false, false); // an earlier edit, kept + let (start, undo, mark) = (opacity(&s), s.history.undo.len(), s.undo_mark()); + set(&mut s, 90.0, true, true); // a merged drag… + set(&mut s, 70.0, true, false); + set(&mut s, 60.0, false, false); // …and changes made one step each + set(&mut s, 50.0, false, false); + assert_eq!(s.revert_to(mark), 3); + assert_eq!(opacity(&s), start, "the value at the mark"); + assert_eq!(s.history.undo.len(), undo, "the earlier step stays"); + assert!(s.history.redo.is_empty(), "nothing to redo"); + assert_eq!(s.revert_to(mark), 0, "nothing left to take back"); + s.undo(); + assert_eq!(opacity(&s), 100.0, "undo still takes back the earlier edit"); +} + +/// A mark no longer in the history does nothing; an older mark still takes back what followed it. +#[test] +fn reverting_to_a_lost_mark_does_nothing() { + let mut s = demo_unlocked(); + let id = v1(&s)[0].0; + let (base, start) = (s.history.undo.len(), s.undo_mark()); + s.execute("effects.setParam", json!({"clip": id, "effect": "opacity", "param": "opacity", "value": 40.0})).unwrap(); + let mark = s.undo_mark(); + s.undo(); + s.execute("effects.setParam", json!({"clip": id, "effect": "opacity", "param": "opacity", "value": 30.0})).unwrap(); + assert_eq!(s.revert_to(mark), 0, "the marked step was undone and replaced"); + assert_eq!(s.revert_to(start), 1); + assert_eq!(s.history.undo.len(), base); +} diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 23b4b6cc1..515624e01 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -722,6 +722,19 @@ pub fn draw_group_chrome_details( if resp.dragged() { dragged = Some(id); } + // Escape puts a dragged tab back where it was (egui ends the drag on Escape, #580) + let home = egui::Id::new(("tab-seq-home", id)); + if resp.drag_started() + && let Some(at) = seqs.open.iter().position(|(s, _)| *s == id) + { + ui.data_mut(|d| d.insert_temp(home, at)); + } + if resp.drag_stopped() + && let Some(at) = ui.data_mut(|d| d.remove_temp::(home)) + && ui.input(|i| i.key_pressed(egui::Key::Escape)) + { + actions.push(DockAction::MoveSequence(id, at)); + } } if let Some(id) = tab.seq { reg.add_response(&format!("timeline.tab.{id}"), &resp, &tab.title); diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index d90888abf..bc368cff7 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -255,6 +255,7 @@ impl FilmcraftApp { } self.panel_drag(&ctx, &targets); panels::panel_menu_popup(self, ui); + self.cancel_value_drag(ui.ctx()); } fn floating_group(&mut self, ctx: &egui::Context, index: usize, seqs: &dock::SeqTabs, actions: &mut Vec) -> Option> { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 438112a29..455bff6f6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -279,6 +279,9 @@ pub struct FilmcraftApp { deferred: Vec<(ControlRequest, f64)>, last_ui_time: f64, pub(crate) synthetic: Vec, + /// The mouse press in progress (its start time) and the undo history when it began, for Escape + /// to take back what the press changed (#580). + value_press: Option<(f64, filmcraft_engine::UndoMark)>, /// BS.1770 loudness of the programme as it plays, and the next sample position to feed. pub(crate) loudness: Option<(filmcraft_audio_dsp::LoudnessMeter, i64)>, /// Status message last shown and when it first appeared (messages expire after a few seconds). @@ -519,6 +522,7 @@ impl FilmcraftApp { deferred: Vec::new(), last_ui_time: 0.0, synthetic: Vec::new(), + value_press: None, loudness: None, status_seen: (String::new(), 0.0), pending_screenshots: Vec::new(), @@ -1651,6 +1655,7 @@ impl FilmcraftApp { } self.command_inbox = Some(rx); } + self.begin_press(&ctx); self.handle_shortcuts(&ctx); self.advance_playback(&ctx); self.advance_source_playback(&ctx); @@ -1788,6 +1793,29 @@ impl FilmcraftApp { } .to_string() } + + /// Remember the undo history as a mouse press begins, before any panel acts on it (a color + /// picker sets its color on the press itself), for [`Self::cancel_value_drag`]. + fn begin_press(&mut self, ctx: &egui::Context) { + match ctx.input(|i| i.pointer.press_start_time().filter(|_| i.pointer.any_down())) { + None => self.value_press = None, + Some(p) if self.value_press.is_some_and(|(q, _)| q == p) => {} + Some(p) => self.value_press = Some((p, self.session.undo_mark())), + } + } + + /// Escape while a mouse button is held takes back every edit this press made (#580): value + /// drags (one merged step or one step per change), color picks, anything committed on the + /// Escape frame. Run after the panels, so a value sent on the Escape frame goes with it. + fn cancel_value_drag(&mut self, ctx: &egui::Context) { + let escape = ctx.input(|i| i.pointer.any_down() && i.key_pressed(egui::Key::Escape)); + if let Some((press, mark)) = self.value_press + && escape + && self.session.revert_to(mark) > 0 + { + self.value_press = Some((press, self.session.undo_mark())); + } + } } impl FilmcraftApp { diff --git a/crates/ui-egui/src/panels/audio_fx_editor.rs b/crates/ui-egui/src/panels/audio_fx_editor.rs index bccdd0ad7..ba86d691a 100644 --- a/crates/ui-egui/src/panels/audio_fx_editor.rs +++ b/crates/ui-egui/src/panels/audio_fx_editor.rs @@ -113,6 +113,19 @@ impl Ed<'_> { // keep showing the new value until the project catches up this frame self.drafts.insert(pid.to_string(), v); } + /// The end of a drag of `pids`: commit their drafts, or drop them when Escape ended the drag + /// (egui ends a drag on Escape, #580). + fn release(&mut self, ui: &egui::Ui, pids: &[&str]) { + let escape = ui.input(|i| i.key_pressed(egui::Key::Escape)); + for pid in pids { + if escape { + self.drafts.remove(*pid); + } else { + let v = self.v(pid); + self.commit(pid, v); + } + } + } fn auto(&mut self, id: String, r: Rect, label: &str) { self.autos.push((id, r, label.to_string())); } @@ -144,7 +157,9 @@ impl Ed<'_> { if r.dragged() { self.drafts.insert(pid.to_string(), v); } - if r.drag_stopped() || (r.changed() && !r.dragged()) { + if r.drag_stopped() { + self.release(ui, &[pid]); + } else if r.changed() && !r.dragged() { self.commit(pid, v); } } @@ -324,12 +339,8 @@ fn parametric(ui: &mut egui::Ui, ed: &mut Ed) { } } if resp.drag_stopped() { - let fv = ed.v(&f_id); - ed.commit(&f_id, fv); - if gain { - let gv = ed.v(&g_id); - ed.commit(&g_id, gv); - } + let pids = if gain { vec![f_id.as_str(), g_id.as_str()] } else { vec![f_id.as_str()] }; + ed.release(ui, &pids); } // scroll over a node changes its Q if gain && resp.hovered() { @@ -393,8 +404,7 @@ fn graphic(ui: &mut egui::Ui, ed: &mut Ed) { ed.drafts.insert(pid.clone(), (db * 2.0).round() / 2.0); } if resp.drag_stopped() { - let nv = ed.v(&pid); - ed.commit(&pid, nv); + ed.release(ui, &[pid.as_str()]); } if resp.double_clicked() { ed.commit(&pid, 0.0); @@ -472,8 +482,7 @@ fn multiband(ui: &mut egui::Ui, ed: &mut Ed) { ed.drafts.insert(pid.to_string(), fx_of(pos.x, r).clamp(lo, hi).round()); } if resp.drag_stopped() { - let v = ed.v(pid); - ed.commit(pid, v); + ed.release(ui, &[pid]); } } ui.add_space(4.0); diff --git a/crates/ui-egui/src/panels/clip_dialogs.rs b/crates/ui-egui/src/panels/clip_dialogs.rs index 2d5b8b536..7667eefe2 100644 --- a/crates/ui-egui/src/panels/clip_dialogs.rs +++ b/crates/ui-egui/src/panels/clip_dialogs.rs @@ -299,6 +299,7 @@ fn frames(ui: &mut egui::Ui, elems: &mut Elems, pre: &str, p: &mut Value, key: & /// Draw the open dialog, if any. pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { let Some(mut d) = app.ui.clip_dialog.clone() else { return }; + crate::widgets::revert_drag_on_escape(ctx, egui::Id::new("clip-dialog-before-drag"), &mut d); let Some((title, pre)) = meta(&d.command) else { app.ui.clip_dialog = None; return; @@ -581,7 +582,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { for (id, r, l) in elems { app.auto.add(&id, r, &l); } - if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + if crate::widgets::escape_closes(ctx) { action = Some("cancel"); } if action.is_some() { diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 40ffadc64..e49e4c412 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -39,7 +39,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { .resizable(false) .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)) .show(ctx, |ui| about(app, ui)); - if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + if crate::widgets::escape_closes(ctx) { open = false; } } @@ -104,6 +104,7 @@ fn audio_gain(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { } }; let mut draft = app.ui.audio_gain.clone(); + crate::widgets::revert_drag_on_escape(ctx, egui::Id::new("audio-gain-before-drag"), &mut draft); let mut keep = true; let mut apply = false; let mut elems: Vec<(String, egui::Rect, String)> = Vec::new(); @@ -153,7 +154,7 @@ fn audio_gain(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { for (id, r, l) in elems { app.auto.add(&id, r, &l); } - if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + if crate::widgets::escape_closes(ctx) { keep = false; } if apply { @@ -188,6 +189,7 @@ fn add_tracks(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { let names = |tracks: &[filmcraft_engine::project::Track]| tracks.iter().map(|t| t.name.clone()).collect::>(); let (vnames, anames, snames) = (names(&seq.video_tracks), names(&seq.audio_tracks), names(&seq.submix_tracks)); let mut d = app.ui.add_tracks.clone(); + crate::widgets::revert_drag_on_escape(ctx, egui::Id::new("add-tracks-before-drag"), &mut d); let mut keep = true; let mut apply = false; let mut elems: Vec<(String, egui::Rect, String)> = Vec::new(); @@ -282,7 +284,7 @@ fn add_tracks(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { for (id, r, l) in elems { app.auto.add(&id, r, &l); } - if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + if crate::widgets::escape_closes(ctx) { keep = false; } if ctx.input(|i| i.key_pressed(egui::Key::Enter)) && !ctx.egui_wants_keyboard_input() { @@ -379,7 +381,7 @@ fn delete_tracks(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { for (id, r, l) in elems { app.auto.add(&id, r, &l); } - if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + if crate::widgets::escape_closes(ctx) { keep = false; } if apply { diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 6b071c4ca..7c0e943c1 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -669,7 +669,10 @@ pub(crate) fn param_row( let off = drag_off.unwrap_or(0.0) + resp.drag_delta().x; ui.data_mut(|d| d.insert_temp(id, off)); } - if resp.drag_stopped() { + // egui ends a drag on Escape: that stop abandons it (#580) + if resp.drag_stopped() && ui.input(|i| i.key_pressed(egui::Key::Escape)) { + ui.data_mut(|d| d.remove::(id)); + } else if resp.drag_stopped() { let off = drag_off.unwrap_or(0.0); ui.data_mut(|d| d.remove::(id)); let new_tl = @@ -960,11 +963,12 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { run(app, ui.ctx(), actions); } -/// Run the panel's actions. Parameter changes made while the mouse button is down (a drag) share -/// one undo step, which the first change of each press begins (#201); typed values and clicks stay -/// separate steps. A drag value only changes once the mouse moves, after the press frame, so the -/// press that already began a step is remembered by its start time. -fn run(app: &mut FilmcraftApp, ctx: &egui::Context, actions: Vec<(String, Value)>) { +/// Run the panel's actions (also the Lumetri Color panel's). Parameter changes made +/// while the mouse button is down (a drag) share one undo step, which the first change of each +/// press begins (#201); typed values and clicks stay separate steps. A drag value only changes once +/// the mouse moves, after the press frame, so the press that already began a step is remembered by +/// its start time. +pub(crate) fn run(app: &mut FilmcraftApp, ctx: &egui::Context, actions: Vec<(String, Value)>) { let (down, press) = ctx.input(|i| (i.pointer.any_down(), i.pointer.press_start_time())); let key = egui::Id::new("effect-controls-drag-step"); for (cmd, mut p) in actions { @@ -1142,7 +1146,9 @@ pub(crate) fn graph_rows( 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() { + if hr.drag_stopped() && ui.input(|i| i.key_pressed(egui::Key::Escape)) { + ui.data_mut(|d| d.remove::(hid)); + } else 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" }; @@ -1158,7 +1164,10 @@ pub(crate) fn graph_rows( 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() { + // egui ends a drag on Escape: that stop abandons it (#580) + if resp.drag_stopped() && ui.input(|i| i.key_pressed(egui::Key::Escape)) { + ui.data_mut(|d| d.remove::(id)); + } else 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 }; diff --git a/crates/ui-egui/src/panels/export_mode.rs b/crates/ui-egui/src/panels/export_mode.rs index 30d21268e..0bf753885 100644 --- a/crates/ui-egui/src/panels/export_mode.rs +++ b/crates/ui-egui/src/panels/export_mode.rs @@ -342,8 +342,21 @@ fn section(ui: &mut egui::Ui, reg: &mut Reg, open: &mut Vec, key: &str, } fn drag(ui: &mut egui::Ui, reg: &mut Reg, id: &str, v: &mut f64, range: std::ops::RangeInclusive, speed: f64, suffix: &str, decimals: usize) -> bool { + let before = *v; let r = ui.add(egui::DragValue::new(v).range(range).speed(speed).suffix(suffix).max_decimals(decimals)); reg.add(id, r.rect, format!("{v}")); + // a drag ended by Escape goes back to the value it started from (egui ends the drag, #580) + let home = r.id.with("before-drag"); + if r.drag_started() { + ui.data_mut(|d| d.insert_temp(home, before)); + } + if r.drag_stopped() + && let Some(b) = ui.data_mut(|d| d.remove_temp::(home)) + && ui.input(|i| i.key_pressed(egui::Key::Escape)) + { + *v = b; + return true; + } r.changed() } @@ -1205,7 +1218,7 @@ fn preset_manager(app: &mut FilmcraftApp, ctx: &egui::Context) { { close = true; } - app.ui.export.manager = if close || !open || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { None } else { Some(m) }; + app.ui.export.manager = if close || !open || crate::widgets::escape_closes(ctx) { None } else { Some(m) }; } /// A five-pointed star (favourite marker), drawn from scratch. @@ -1326,7 +1339,7 @@ pub fn quick_export(app: &mut FilmcraftApp, ctx: &egui::Context, anchor: egui::P } close = true; } - if close || ctx.input(|i| i.key_pressed(egui::Key::Escape)) || (clicked_elsewhere && !toggled) { + if close || crate::widgets::escape_closes(ctx) || (clicked_elsewhere && !toggled) { app.ui.export.quick_open = false; } } diff --git a/crates/ui-egui/src/panels/file_dialogs.rs b/crates/ui-egui/src/panels/file_dialogs.rs index 6266b59fa..26015da3a 100644 --- a/crates/ui-egui/src/panels/file_dialogs.rs +++ b/crates/ui-egui/src/panels/file_dialogs.rs @@ -130,7 +130,8 @@ pub fn show_revert(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { }); }); }); - open && !r.should_close() + // (the modal's own close also fires on Escape: not while a drag is being cancelled, #580) + open && !(r.should_close() && !ctx.input(|i| i.pointer.any_down())) } /// Draw the dialog if it is one of ours; returns Some(still open). diff --git a/crates/ui-egui/src/panels/frame_export.rs b/crates/ui-egui/src/panels/frame_export.rs index 4790c440a..33ec63f7e 100644 --- a/crates/ui-egui/src/panels/frame_export.rs +++ b/crates/ui-egui/src/panels/frame_export.rs @@ -208,7 +208,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { for (id, rect, label) in elems { app.auto.add(&id, rect, &label); } - close |= ctx.input(|i| i.key_pressed(egui::Key::Escape)); + close |= crate::widgets::escape_closes(ctx); if browse && !close { let folder = filmcraft_engine::export_tools::expand_home(d.folder.trim()); let picked = match app.hooks.pick_folder_at.as_mut() { diff --git a/crates/ui-egui/src/panels/graphics.rs b/crates/ui-egui/src/panels/graphics.rs index 7cf5f47b3..558e4dcb6 100644 --- a/crates/ui-egui/src/panels/graphics.rs +++ b/crates/ui-egui/src/panels/graphics.rs @@ -753,7 +753,8 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra } if resp.drag_stopped() { ui.data_mut(|dd| dd.remove::(drag_id)); - let moved = (cur - d.start).length() > 2.0; + // egui ends a drag on Escape: that stop abandons it (#580) + let moved = (cur - d.start).length() > 2.0 && !ui.input(|i| i.key_pressed(egui::Key::Escape)); match d.kind { DragKind::Move if moved => { if let Some(v) = v { @@ -1792,7 +1793,7 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { pub fn dialogs(app: &mut FilmcraftApp, ctx: &egui::Context) { let Some(mut d) = app.ui.text_props_dialog.clone() else { return }; let mut elems: Vec<(String, Rect, &str)> = Vec::new(); - let mut close = ctx.input(|i| i.key_pressed(egui::Key::Escape)); + let mut close = crate::widgets::escape_closes(ctx); let mut ok = false; let name = |paragraph: bool| if paragraph { tl!("Paragraph Text") } else { tl!("Point Text") }; let id = egui::Id::new("gfx-text-properties"); diff --git a/crates/ui-egui/src/panels/interchange_export.rs b/crates/ui-egui/src/panels/interchange_export.rs index 205cdafa9..427572e9d 100644 --- a/crates/ui-egui/src/panels/interchange_export.rs +++ b/crates/ui-egui/src/panels/interchange_export.rs @@ -62,6 +62,7 @@ pub fn is_open(ctx: &egui::Context) -> bool { pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { let Some(Some(mut d)) = ctx.data(|m| m.get_temp::>(draft_id())) else { return }; + crate::widgets::revert_drag_on_escape(ctx, egui::Id::new("interchange-export-before-drag"), &mut d); let p = if d.omf { "omfExport" } else { "aafExport" }; let mut close = false; let mut apply = false; @@ -168,7 +169,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { for (id, r, l) in elems { app.auto.add(&id, r, &l); } - if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + if crate::widgets::escape_closes(ctx) { close = true; } if apply { diff --git a/crates/ui-egui/src/panels/lumetri.rs b/crates/ui-egui/src/panels/lumetri.rs index ff35ec199..3a9e2da76 100644 --- a/crates/ui-egui/src/panels/lumetri.rs +++ b/crates/ui-egui/src/panels/lumetri.rs @@ -235,11 +235,8 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add(&id, r, label); } } - for a in actions { - if let Err(err) = app.session.execute("effects.setParam", a) { - app.ui.status = err.to_string(); - } - } + // a dragged value is one undo step, as in Effect Controls + crate::panels::effect_controls::run(app, ui.ctx(), actions.into_iter().map(|a| ("effects.setParam".to_string(), a)).collect()); for sec in sections { if let Err(err) = app.session.execute("lumetri.setSection", json!({"clip": clip.0, "section": sec})) { app.ui.status = err.to_string(); diff --git a/crates/ui-egui/src/panels/media_browser.rs b/crates/ui-egui/src/panels/media_browser.rs index 592abb20e..256fc6fa7 100644 --- a/crates/ui-egui/src/panels/media_browser.rs +++ b/crates/ui-egui/src/panels/media_browser.rs @@ -232,10 +232,22 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut sz = prefs.thumbnail_size; let resp = ui.put(sr, egui::Slider::new(&mut sz, 60.0..=320.0).show_value(false)); app.auto.add("mediaBrowser.thumbnailSize", sr, "Thumbnail size"); + // a drag ended by Escape goes back to the size it started from (#580) + let orig_id = egui::Id::new("media-browser-thumbnail-size-before-drag"); + if resp.drag_started() { + ui.data_mut(|d| d.insert_temp(orig_id, prefs.thumbnail_size)); + } + let escaped = resp.drag_stopped() && ui.input(|i| i.key_pressed(egui::Key::Escape)); + if resp.drag_stopped() + && let Some(before) = ui.data_mut(|d| d.remove_temp(orig_id)) + && escaped + { + sz = before; + } if sz != prefs.thumbnail_size { app.session.prefs.media_browser.thumbnail_size = sz; } - if resp.drag_stopped() || (resp.changed() && !resp.dragged()) { + if !escaped && (resp.drag_stopped() || (resp.changed() && !resp.dragged())) { exec(app, &ctx, "mediaBrowser.settings", json!({"thumbnailSize": sz})); } } @@ -839,5 +851,5 @@ pub fn dialogs(app: &mut FilmcraftApp, ctx: &egui::Context) { } close = true; } - app.ui.media_browser.edit_columns = if close || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { None } else { Some(cols) }; + app.ui.media_browser.edit_columns = if close || crate::widgets::escape_closes(ctx) { None } else { Some(cols) }; } diff --git a/crates/ui-egui/src/panels/media_dialogs.rs b/crates/ui-egui/src/panels/media_dialogs.rs index cef30cd83..c61adef31 100644 --- a/crates/ui-egui/src/panels/media_dialogs.rs +++ b/crates/ui-egui/src/panels/media_dialogs.rs @@ -298,7 +298,7 @@ fn link_media(app: &mut FilmcraftApp, ctx: &egui::Context) { for (id, r, l) in elems { app.auto.add(&id, r, &l); } - if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + if crate::widgets::escape_closes(ctx) { action = Some("cancel"); } let (item, ..) = rows[d.row].clone(); @@ -424,7 +424,7 @@ fn make_offline(app: &mut FilmcraftApp, ctx: &egui::Context) { } keep = false; } - app.ui.make_offline = (keep && !ctx.input(|i| i.key_pressed(egui::Key::Escape))).then_some(delete); + app.ui.make_offline = (keep && !crate::widgets::escape_closes(ctx)).then_some(delete); } fn create_proxies(app: &mut FilmcraftApp, ctx: &egui::Context) { @@ -483,7 +483,7 @@ fn create_proxies(app: &mut FilmcraftApp, ctx: &egui::Context) { } keep = false; } - app.ui.create_proxies = (keep && !ctx.input(|i| i.key_pressed(egui::Key::Escape))).then_some(d); + app.ui.create_proxies = (keep && !crate::widgets::escape_closes(ctx)).then_some(d); } fn pm_params(d: &ProjectManagerDraft, dry: bool) -> Value { @@ -502,6 +502,7 @@ fn pm_params(d: &ProjectManagerDraft, dry: bool) -> Value { fn project_manager(app: &mut FilmcraftApp, ctx: &egui::Context) { let Some(mut d) = app.ui.project_manager.clone() else { return }; + crate::widgets::revert_drag_on_escape(ctx, egui::Id::new("project-manager-before-drag"), &mut d); let before = d.clone(); let mut elems: Elems = Vec::new(); let mut keep = true; @@ -633,5 +634,5 @@ fn project_manager(app: &mut FilmcraftApp, ctx: &egui::Context) { Err(e) => d.message = e.to_string(), } } - app.ui.project_manager = (keep && !ctx.input(|i| i.key_pressed(egui::Key::Escape))).then_some(d); + app.ui.project_manager = (keep && !crate::widgets::escape_closes(ctx)).then_some(d); } diff --git a/crates/ui-egui/src/panels/menu_dialogs.rs b/crates/ui-egui/src/panels/menu_dialogs.rs index 1d35c21e8..0891b8bae 100644 --- a/crates/ui-egui/src/panels/menu_dialogs.rs +++ b/crates/ui-egui/src/panels/menu_dialogs.rs @@ -446,6 +446,7 @@ fn pairs(v: &[&str]) -> Vec<(String, String)> { /// Draw the open dialog, if any. pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { let Some(mut d) = app.ui.extras.dialog.clone() else { return }; + crate::widgets::revert_drag_on_escape(ctx, egui::Id::new("menu-dialog-before-drag"), &mut d); let Some((title, pre, ok_text)) = meta(&d.command) else { app.ui.extras.dialog = None; return; @@ -828,7 +829,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { { d.params[k.as_str()] = json!(dir); } - if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + if crate::widgets::escape_closes(ctx) { action = Some("cancel"); } match action { diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 768a9474a..cc8926ffe 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -339,7 +339,7 @@ pub fn panel_menu_popup(app: &mut FilmcraftApp, ui: &mut egui::Ui) { // A click elsewhere or Escape closes the menu. The click that opened it is over the tab, not // the menu, and must not close it again in the same frame. let fresh = ui.ctx().data(|d| d.get_temp::(egui::Id::new("panel-menu-opened"))) == Some(ui.ctx().cumulative_frame_nr()); - if close || (!fresh && area.response.clicked_elsewhere()) || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + if close || (!fresh && area.response.clicked_elsewhere()) || crate::widgets::escape_closes(ui.ctx()) { ui.ctx().data_mut(|d| d.remove::<(PanelKind, egui::Pos2)>(id)); } } diff --git a/crates/ui-egui/src/panels/monitor_view.rs b/crates/ui-egui/src/panels/monitor_view.rs index 501ead7a5..fffe84e09 100644 --- a/crates/ui-egui/src/panels/monitor_view.rs +++ b/crates/ui-egui/src/panels/monitor_view.rs @@ -523,7 +523,8 @@ pub fn rulers(app: &mut FilmcraftApp, ui: &mut egui::Ui, w: Which, top: Rect, le } if resp.drag_stopped() { ui.data_mut(|d| d.remove::(drag_id)); - if area.contains(pos) { + // egui ends a drag on Escape: that stop adds no guide (#580) + if area.contains(pos) && !ui.input(|i| i.key_pressed(egui::Key::Escape)) { let position = if ng.vertical { ((pos.x - pic.min.x) / kx).round() } else { ((pos.y - pic.min.y) / ky).round() } as f64; let v = view_mut(app, w); v.guides.push(Guide { vertical: ng.vertical, position }); @@ -561,6 +562,20 @@ pub fn guides(app: &mut FilmcraftApp, ui: &mut egui::Ui, w: Which, area: Rect, p if resp.hovered() || resp.dragged() { ui.ctx().set_cursor_icon(if g.vertical { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical }); } + // egui ends a drag on Escape: that stop puts the guide back where it was (#580) + let before_id = egui::Id::new((pfx, "guide-before-drag", i)); + if resp.drag_started() { + ui.data_mut(|d| d.insert_temp(before_id, g.position)); + } + if resp.drag_stopped() { + let before = ui.data_mut(|d| d.remove_temp::(before_id)); + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + if let (Some(p0), Some(guide)) = (before, view_mut(app, w).guides.get_mut(i)) { + guide.position = p0; + } + continue; + } + } if let Some(pos) = resp.interact_pointer_pos() && (resp.dragged() || resp.drag_stopped()) { @@ -775,8 +790,9 @@ fn push(e: &mut Elems, id: &str, r: &egui::Response, label: &str) { /// Add Guide… / Save Guides as Template… / Manage Guides… dialogs. pub fn dialogs(app: &mut FilmcraftApp, ctx: &egui::Context) { let Some(mut d) = app.ui.guide_dialog.clone() else { return }; + crate::widgets::revert_drag_on_escape(ctx, egui::Id::new("guide-dialog-before-drag"), &mut d); let mut elems: Elems = Vec::new(); - let mut close = ctx.input(|i| i.key_pressed(egui::Key::Escape)); + let mut close = crate::widgets::escape_closes(ctx); let mut act: Option<(String, Value)> = None; let templates: Vec = app.session.prefs.guides.templates.iter().map(|t| t.name.clone()).collect(); let (title, shown) = match &d { diff --git a/crates/ui-egui/src/panels/multicam.rs b/crates/ui-egui/src/panels/multicam.rs index b5403b3d6..4fa87de29 100644 --- a/crates/ui-egui/src/panels/multicam.rs +++ b/crates/ui-egui/src/panels/multicam.rs @@ -93,6 +93,7 @@ fn methods() -> [(&'static str, &'static str); 5] { /// Draw the open Synchronize / Merge Clips / Create Multi-Camera dialog. pub fn show_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { let Some(mut d) = app.ui.sync_dialog.clone() else { return }; + crate::widgets::revert_drag_on_escape(ctx, egui::Id::new("sync-dialog-before-drag"), &mut d); let (title, pre) = match d.kind.as_str() { "merge" => (tl!("Merge Clips"), "merge"), "multicam" => (tl!("Create Multi-Camera Source Sequence"), "mcam"), @@ -213,7 +214,7 @@ pub fn show_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { for (id, r, l) in elems { app.auto.add(&id, r, &l); } - if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + if crate::widgets::escape_closes(ctx) { action = Some("cancel"); } match action { @@ -498,7 +499,7 @@ pub fn show_edit_cameras(app: &mut FilmcraftApp, ctx: &egui::Context) { for (id, r, l) in elems { app.auto.add(&id, r, &l); } - if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + if crate::widgets::escape_closes(ctx) { action = Some("cancel"); } match action { diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 95932cb16..9a0e167d8 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -579,13 +579,25 @@ fn footer(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, v: &View, action let mut sz = v.icon_size; let resp = ui.put(sr, egui::Slider::new(&mut sz, 48.0..=400.0).show_value(false)); app.auto.add(&format!("{pre}.iconSize"), sr, "Zoom"); + // a drag ended by Escape goes back to the size it started from (#580) + let orig_id = egui::Id::new((pre, "icon-size-before-drag")); + if resp.drag_started() { + ui.data_mut(|d| d.insert_temp(orig_id, v.icon_size)); + } + let escaped = resp.drag_stopped() && ui.input(|i| i.key_pressed(egui::Key::Escape)); + if resp.drag_stopped() + && let Some(before) = ui.data_mut(|d| d.remove_temp(orig_id)) + && escaped + { + sz = before; + } if sz != v.icon_size { match v.inst { Inst::Main => app.session.prefs.project_panel.view.icon_size = sz, Inst::Tab(k) => app.ui.project_panel.tabs[k].icon_size = sz, } } - if v.inst == Inst::Main && (resp.drag_stopped() || (resp.changed() && !resp.dragged())) { + if v.inst == Inst::Main && !escaped && (resp.drag_stopped() || (resp.changed() && !resp.dragged())) { actions.push(("project.view.set".into(), json!({"iconSize": sz}))); } x = sr.max.x + 6.0; diff --git a/crates/ui-egui/src/panels/project_dialogs.rs b/crates/ui-egui/src/panels/project_dialogs.rs index c16d3a6ef..582fb9850 100644 --- a/crates/ui-egui/src/panels/project_dialogs.rs +++ b/crates/ui-egui/src/panels/project_dialogs.rs @@ -15,6 +15,7 @@ fn push(app: &mut FilmcraftApp, id: &str, r: &egui::Response, label: &str) { pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { let Some(mut d) = app.ui.project_panel.dialog.clone() else { return }; + crate::widgets::revert_drag_on_escape(ctx, egui::Id::new("project-dialog-before-drag"), &mut d); let mut close = false; let mut run: Option<(String, Value)> = None; let title = match &d { @@ -82,7 +83,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { } } }); - if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + if crate::widgets::escape_closes(ctx) { close = true; } if let Some((cmd, p)) = run { diff --git a/crates/ui-egui/src/panels/remix.rs b/crates/ui-egui/src/panels/remix.rs index 459fcdd92..63307ec47 100644 --- a/crates/ui-egui/src/panels/remix.rs +++ b/crates/ui-egui/src/panels/remix.rs @@ -44,6 +44,7 @@ pub fn is_open(ctx: &egui::Context) -> bool { pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { let Some(Some(mut d)) = ctx.data(|m| m.get_temp::>(draft_id())) else { return }; + crate::widgets::revert_drag_on_escape(ctx, egui::Id::new("remix-dialog-before-drag"), &mut d); let mut close = false; let mut apply = false; let mut elems: Vec<(String, egui::Rect, String)> = Vec::new(); @@ -88,7 +89,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { for (id, r, l) in elems { app.auto.add(&id, r, &l); } - if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + if crate::widgets::escape_closes(ctx) { close = true; } if apply { diff --git a/crates/ui-egui/src/panels/sequence_settings.rs b/crates/ui-egui/src/panels/sequence_settings.rs index d7fe5d025..fe9560790 100644 --- a/crates/ui-egui/src/panels/sequence_settings.rs +++ b/crates/ui-egui/src/panels/sequence_settings.rs @@ -475,6 +475,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { let audio_samples = app.session.project.settings.audio_display_samples; let accent = app.tokens.accent; let mut d = app.ui.sequence_settings.clone(); + crate::widgets::revert_drag_on_escape(ctx, egui::Id::new("sequence-settings-before-drag"), &mut d); let mut keep = true; let mut apply = false; let mut elems: Elems = Vec::new(); @@ -526,7 +527,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { for (id, r, l) in elems { app.auto.add(&id, r, &l); } - if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + if crate::widgets::escape_closes(ctx) { keep = false; } if ctx.input(|i| i.key_pressed(egui::Key::Enter)) && !ctx.egui_wants_keyboard_input() { diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 336ba0970..d26a69146 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -691,6 +691,7 @@ fn rows(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, list: &[Row] /// Draw the dialog; returns whether it stays open. pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { let Some(mut d) = app.ui.settings.take() else { return false }; + crate::widgets::revert_drag_on_escape(ctx, egui::Id::new("settings-dialog-before-drag"), &mut d); let t = app.tokens; let mut close = false; let mut apply = false; @@ -781,7 +782,8 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { Err(e) => d.message = e.to_string(), } } - if resp.should_close() || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + // (the modal's own close also fires on Escape: not while a drag is being cancelled) + if (resp.should_close() && !ctx.input(|i| i.pointer.any_down())) || crate::widgets::escape_closes(ctx) { close = true; } if !close { diff --git a/crates/ui-egui/src/panels/text.rs b/crates/ui-egui/src/panels/text.rs index 842000955..b6bc21552 100644 --- a/crates/ui-egui/src/panels/text.rs +++ b/crates/ui-egui/src/panels/text.rs @@ -414,7 +414,9 @@ fn style_strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, track_idx: us 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"); - if resp.drag_stopped() || (resp.changed() && !resp.dragged()) { + // a drag ended by Escape keeps the size it had (#580) + let escaped = resp.drag_stopped() && ui.input(|i| i.key_pressed(egui::Key::Escape)); + if !escaped && (resp.drag_stopped() || (resp.changed() && !resp.dragged())) { actions.push(("captions.setStyle".into(), json!({"track": tr.id.0, "size": size}))); } let mut col = Color32::from_rgba_unmultiplied(st.color[0], st.color[1], st.color[2], st.color[3]); diff --git a/crates/ui-egui/src/panels/trim_monitor.rs b/crates/ui-egui/src/panels/trim_monitor.rs index 2cb4838ed..ba65b9474 100644 --- a/crates/ui-egui/src/panels/trim_monitor.rs +++ b/crates/ui-egui/src/panels/trim_monitor.rs @@ -258,7 +258,8 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { if resp.drag_stopped() { let total = ui.ctx().data_mut(|d| d.remove_temp::(egui::Id::new("trim-monitor-dx")).unwrap_or(0.0)); let n = (total * fpp).round() as i64; - if n != 0 { + // egui ends a drag on Escape: that stop abandons it (#580) + if n != 0 && !ui.input(|i| i.key_pressed(egui::Key::Escape)) { drag_trim(app, which, n); } } diff --git a/crates/ui-egui/src/panels/voiceover.rs b/crates/ui-egui/src/panels/voiceover.rs index 292bc398f..0384ec290 100644 --- a/crates/ui-egui/src/panels/voiceover.rs +++ b/crates/ui-egui/src/panels/voiceover.rs @@ -231,6 +231,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { fn settings_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { let Some(Some(mut d)) = ctx.data(|m| m.get_temp::>(draft_id())) else { return }; + crate::widgets::revert_drag_on_escape(ctx, egui::Id::new("voiceover-settings-before-drag"), &mut d); let mut elems: Vec<(String, Rect, String)> = Vec::new(); let mut close = false; let mut apply = false; @@ -337,7 +338,7 @@ fn settings_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { Err(error) => app.ui.status = error.to_string(), } } - if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + if crate::widgets::escape_closes(ctx) { close = true; } if apply { diff --git a/crates/ui-egui/src/panels/workspaces.rs b/crates/ui-egui/src/panels/workspaces.rs index fd7e8d697..2093f0860 100644 --- a/crates/ui-egui/src/panels/workspaces.rs +++ b/crates/ui-egui/src/panels/workspaces.rs @@ -172,7 +172,7 @@ pub fn dialogs(app: &mut FilmcraftApp, ctx: &egui::Context) { let Some(mut d) = app.ui.workspace_dialog.clone() else { return }; let mut elems: Vec<(String, egui::Rect, String)> = Vec::new(); let mut push = |id: &str, r: &egui::Response, label: &str| elems.push((id.to_string(), r.rect, label.to_string())); - let mut close = ctx.input(|i| i.key_pressed(egui::Key::Escape)); + let mut close = crate::widgets::escape_closes(ctx); let mut act: Option<(&str, Value)> = None; let names = dock::names(&app.workspaces); let changed: Vec = app.workspaces.saved.iter().map(|s| s.name.clone()).collect(); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index d57b01ebe..ad899ec46 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -163,6 +163,35 @@ pub fn timecode_field( out } +/// Escape closes a dialog or popup only when no mouse button is held: with one held it cancels the +/// drag in progress instead (#580). +pub fn escape_closes(ctx: &egui::Context) -> bool { + ctx.input(|i| i.key_pressed(egui::Key::Escape) && !i.pointer.any_down()) +} + +/// Keep a dialog's `draft` as a mouse press begins; Escape while the button is held puts it back, +/// so a dragged number, slider or color returns to its value (#580). Call before drawing the +/// dialog's widgets, and close the dialog with [`escape_closes`] so the same Escape keeps it open. +/// The draft is put back on every frame until the button comes up: on the Escape frame itself a +/// color picker's popup, drawn after this call, still sets its color from the pointer. +pub fn revert_drag_on_escape(ctx: &egui::Context, id: egui::Id, draft: &mut T) { + let (pressed, down, escape) = ctx.input(|i| (i.pointer.any_pressed(), i.pointer.any_down(), i.key_pressed(egui::Key::Escape))); + if pressed && down { + ctx.data_mut(|d| d.insert_temp(id, (draft.clone(), false))); + return; + } + // (the draft when the press began, whether Escape cancelled the drag) + let Some((before, cancelled)) = ctx.data(|d| d.get_temp::<(T, bool)>(id)) else { return }; + if escape || cancelled { + *draft = before.clone(); + } + if !down { + ctx.data_mut(|d| d.remove::<(T, bool)>(id)); + } else if escape && !cancelled { + ctx.data_mut(|d| d.insert_temp(id, (before, true))); + } +} + /// A twirl-down section header (Effect Controls / Lumetri style). Returns open state. pub fn section_header(ui: &mut Ui, id: egui::Id, title: &str, open: bool, t: &Tokens, bold: bool) -> (Response, bool) { let w = ui.available_width(); diff --git a/crates/ui-egui/tests/clip_dialogs_ui.rs b/crates/ui-egui/tests/clip_dialogs_ui.rs index b60493718..78f8acd47 100644 --- a/crates/ui-egui/tests/clip_dialogs_ui.rs +++ b/crates/ui-egui/tests/clip_dialogs_ui.rs @@ -341,3 +341,47 @@ fn quit_save_on_a_new_project_uses_the_save_dialog_and_scripted_quit_does_not_as d.ok("app.quit", json!({})); assert!(d.app().quit_confirmed); } + +/// #580: Escape while dragging a value in a dialog puts the value back and keeps the dialog open; +/// Escape with no drag still closes it. +#[test] +fn escape_mid_drag_reverts_the_field_and_keeps_the_dialog() { + let mut d = Driver::new(); + let c = d.v1(0); + d.exec("timeline.select", json!({"clips": [c.id.0]})); + d.menu("clip.speedDuration"); + let els = d.ok("ui.elements", json!({"prefix": "speedDuration.speed"})); + let r: Vec = els.as_array().unwrap().iter().find(|e| e["id"] == "speedDuration.speed").expect("speed field")["rect"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_f64().unwrap() as f32) + .collect(); + let from = egui::pos2(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0); + let speed = |d: &mut Driver| d.app().ui.clip_dialog.as_ref().map(|g| g.params["speed"].clone()); + let drag = |d: &mut Driver, escape: bool| { + let mut send = |e: egui::Event| { + d.harness.input_mut().events.push(e); + d.frames(1); + }; + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + send(egui::Event::PointerMoved(from)); + send(button(from, true)); + for k in 1..=10 { + send(egui::Event::PointerMoved(from + egui::vec2(4.0 * k as f32, 0.0))); + if escape && k == 5 { + send(egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }); + } + } + send(button(from + egui::vec2(40.0, 0.0), false)); + d.frames(3); + }; + drag(&mut d, true); + assert_eq!(speed(&mut d), Some(json!(100.0)), "the speed it had before the drag, and the dialog is still open"); + drag(&mut d, false); + assert_ne!(speed(&mut d), Some(json!(100.0)), "without Escape the same drag changes the speed"); + d.ok("ui.key", json!({"key": "Escape"})); + d.frames(2); + assert!(d.app().ui.clip_dialog.is_none(), "Escape with no drag closes the dialog"); + assert_eq!(d.v1(0).speed, 1.0, "and applies nothing"); +} diff --git a/crates/ui-egui/tests/escape_drag_ui.rs b/crates/ui-egui/tests/escape_drag_ui.rs index 51c74b6f5..bfe4e80f7 100644 --- a/crates/ui-egui/tests/escape_drag_ui.rs +++ b/crates/ui-egui/tests/escape_drag_ui.rs @@ -158,6 +158,81 @@ fn escape_puts_a_freeform_card_back_and_the_next_drag_works() { assert!(meta(&d), "after a cancel the next drag places the card"); } +#[test] +fn escape_takes_back_a_value_drag_in_properties() { + let mut d = Driver::demo(); + let clip = d.ok("engine.execute", json!({"command": "graphics.newText", "params": {"text": "Hi"}}))["clip"].as_u64().expect("graphic clip"); + d.ok("engine.execute", json!({"command": "timeline.select", "params": {"clips": [clip]}})); + d.ok("ui.panel.show", json!({"panel": "Properties"})); + d.frames(4); + let from = centre(d.rect("graphics.prop.scale")); + let to = from + egui::vec2(40.0, 0.0); + let (before, undo) = (d.project(), d.undo_len()); + d.drag(from, to, true); + assert_eq!(d.project(), before, "the scale is back to what it was"); + assert_eq!(d.undo_len(), undo, "nothing to undo"); + assert!(d.harness.state().session.history.redo.is_empty(), "nothing to redo"); + d.drag(from, to, false); + assert_ne!(d.project(), before, "without Escape the same drag changes the scale"); + assert_eq!(d.undo_len(), undo + 1, "as one undo step"); +} + +/// The color picker applies colors as you drag in it; Escape takes all of them back. +#[test] +fn escape_takes_back_a_color_picker_drag() { + let mut d = Driver::demo(); + let clip = d.ok("engine.execute", json!({"command": "graphics.newText", "params": {"text": "Hi"}}))["clip"].as_u64().expect("graphic clip"); + d.ok("engine.execute", json!({"command": "timeline.select", "params": {"clips": [clip]}})); + d.ok("ui.panel.show", json!({"panel": "Properties"})); + d.ok("ui.set", json!({"focused": "Properties"})); + d.ok("ui.menu.invoke", json!({"id": "window.maximizeFrame"})); + d.frames(4); + // the picker's popup, and in it the saturation / value square under the RGB row and swatch + // scroll the Fill color up into the window, with room under it for the picker + for _ in 0..10 { + if d.rect("graphics.prop.fill_color")[1] < 500.0 { + break; + } + d.ok("ui.scroll", json!({"id": "graphics.prop.scale", "dy": -200.0})); + } + let picker = |d: &mut Driver| { + d.ok("ui.click", json!({"id": "graphics.prop.fill_color"})); + let popup = d.harness.ctx.memory(|m| { + m.areas() + .visible_layer_ids() + .into_iter() + .filter(|l| l.order == egui::Order::Foreground) + .filter_map(|l| m.area_rect(l.id)) + .max_by(|a, b| a.area().total_cmp(&b.area())) + }); + let r = popup.expect("the color picker opened"); + (r.min + egui::vec2(30.0, 90.0), r.min + egui::vec2(70.0, 120.0)) + }; + let (before, undo) = (d.project(), d.undo_len()); + let (from, to) = picker(&mut d); + d.drag(from, to, true); + assert_eq!(d.project(), before, "the fill color is back to what it was"); + assert_eq!(d.undo_len(), undo, "nothing to undo"); + d.ok("ui.key", json!({"key": "Escape"})); + let (from, to) = picker(&mut d); + d.drag(from, to, false); + assert_ne!(d.project(), before, "without Escape the same drag picks a color"); +} + +#[test] +fn escape_puts_a_thumbnail_size_slider_back() { + let mut d = Driver::demo(); + d.footage_in("icon"); + let size = |d: &Driver| d.harness.state().session.prefs.project_panel.view.icon_size; + let start = size(&d); + let r = d.rect("project.iconSize"); + let (from, to) = (egui::pos2(r[0] + r[2] * 0.3, r[1] + r[3] / 2.0), egui::pos2(r[0] + r[2] * 0.9, r[1] + r[3] / 2.0)); + d.drag(from, to, true); + assert_eq!(size(&d), start, "the slider went back"); + d.drag(from, to, false); + assert_ne!(size(&d), start, "without Escape the same drag resizes"); +} + #[test] fn escape_cancels_dragging_an_effect_onto_a_clip() { let mut d = Driver::demo(); diff --git a/crates/ui-egui/tests/settings_ui.rs b/crates/ui-egui/tests/settings_ui.rs index eca13d504..ad4c6a1b3 100644 --- a/crates/ui-egui/tests/settings_ui.rs +++ b/crates/ui-egui/tests/settings_ui.rs @@ -200,6 +200,96 @@ fn checkbox_edits_apply_on_ok_and_not_on_cancel() { assert_eq!(d.pref("timeline.snapPlayhead"), false, "on by default (as in Premiere), the click turns it off"); } +/// #580: Escape while dragging a number in Settings puts it back and keeps the dialog open (the +/// modal's own Escape close included); Escape with no drag still cancels the dialog. +#[test] +fn escape_mid_drag_reverts_a_setting_and_keeps_the_dialog() { + let mut d = Driver::demo(); + d.menu("app.settings.playback"); + let els = d.ok("ui.elements", json!({"prefix": "settings.playback.stepManyFrames"})); + let r: Vec = els.as_array().unwrap().iter().find(|e| e["id"] == "settings.playback.stepManyFrames").expect("number field")["rect"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_f64().unwrap() as f32) + .collect(); + let from = egui::pos2(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0); + let drag = |d: &mut Driver, escape: bool| { + let mut send = |e: egui::Event| { + d.harness.input_mut().events.push(e); + d.frames(1); + }; + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + send(egui::Event::PointerMoved(from)); + send(button(from, true)); + for k in 1..=10 { + send(egui::Event::PointerMoved(from + egui::vec2(6.0 * k as f32, 0.0))); + if escape && k == 5 { + send(egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }); + } + } + send(button(from + egui::vec2(60.0, 0.0), false)); + d.frames(3); + }; + let before = d.draft(); + drag(&mut d, true); + assert_eq!(d.draft(), before, "the value it had before the drag, and the dialog is still open"); + drag(&mut d, false); + assert_ne!(d.draft(), before, "without Escape the same drag changes it"); + d.ok("ui.key", json!({"key": "Escape"})); + d.frames(2); + assert_eq!(d.draft(), Value::Null, "Escape with no drag closes the dialog"); +} + +/// #580: the color pickers in Settings take back a drag on Escape too, as everywhere else. +#[test] +fn escape_mid_drag_reverts_a_settings_color() { + let mut d = Driver::demo(); + d.menu("app.settings.appearance"); + // the areas on top: the Settings modal, and the picker's popup over it when it is open + let on_top = |d: &Driver| { + d.harness.ctx.memory(|m| { + let mut v: Vec = + m.areas().visible_layer_ids().into_iter().filter(|l| l.order == egui::Order::Foreground).filter_map(|l| m.area_rect(l.id)).collect(); + v.sort_by(|a, b| a.area().total_cmp(&b.area())); + v + }) + }; + let picker = |d: &mut Driver| { + if on_top(d).len() < 2 { + d.ok("ui.click", json!({"id": "settings.appearance.highlightColor"})); + } + let r = *on_top(d).first().expect("the color picker opened"); + (r.min + egui::vec2(30.0, 90.0), r.min + egui::vec2(70.0, 120.0)) + }; + let drag = |d: &mut Driver, from: egui::Pos2, to: egui::Pos2, escape: bool| { + let mut send = |e: egui::Event| { + d.harness.input_mut().events.push(e); + d.frames(1); + }; + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + send(egui::Event::PointerMoved(from)); + send(button(from, true)); + for k in 1..=10 { + send(egui::Event::PointerMoved(from.lerp(to, k as f32 / 10.0))); + if escape && k == 5 { + send(egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }); + } + } + send(button(to, false)); + d.frames(3); + }; + let color = |d: &mut Driver| d.draft()["values"]["appearance"]["highlightColor"].clone(); + let before = color(&mut d); + let (from, to) = picker(&mut d); + drag(&mut d, from, to, true); + assert_eq!(color(&mut d), before, "the highlight color it had before the drag"); + assert_ne!(d.draft(), Value::Null, "the dialog is still open"); + let (from, to) = picker(&mut d); + drag(&mut d, from, to, false); + assert_ne!(color(&mut d), before, "without Escape the same drag picks a color"); +} + #[test] fn dropdowns_numbers_and_reset() { let mut d = Driver::demo(); From d22775dd41cd530db33082f7e0d2abb6f341b263 Mon Sep 17 00:00:00 2001 From: Oskar Date: Sun, 11 Oct 2026 12:58:23 +0200 Subject: [PATCH 216/260] audio: 5.1 sources on a stereo track fold down instead of losing C and surrounds A six-channel clip on a stereo track played only source channels 0 and 1, so the centre (dialogue in film mixes), LFE and surrounds were dropped. Fold it down with ITU-R BS.775, as the Mix and the Source Monitor already do. A six-channel Modify > Audio Channels pick folds the same way; mono and stereo picks keep playing exactly the picked channels. --- crates/render/src/audio.rs | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/render/src/audio.rs b/crates/render/src/audio.rs index 404ee794f..fd211c243 100644 --- a/crates/render/src/audio.rs +++ b/crates/render/src/audio.rs @@ -351,7 +351,8 @@ pub fn clip_is_mono(item: &TrackItem, sources: &dyn SourceProvider) -> Option Vec> { let mut out = vec![vec![0.0f32; len]; w]; let item_s0 = item.start.to_units_floor(sr as i64); @@ -394,6 +395,17 @@ fn raw_channels(item: &TrackItem, src: SourceAudio, x0: i64, len: usize, sr: u32 return out; } if w == 2 { + // a 5.1 source (or a six-channel pick) folds down with ITU-R BS.775, as the Mix and the + // Source Monitor do: picking its first two channels would drop C (dialogue) and surrounds + if let Some(six) = source_51_fold(item, buf.channel_count()) { + let picked: Vec<&[f32]> = six.iter().filter_map(|&c| buf.channels.get(c).map(Vec::as_slice)).collect(); + let conv = chans::convert(&picked, Layout::Surround51, Layout::Stereo, Mixdown::FrontRear); + for (dst, ch) in out.iter_mut().zip(&conv) { + let m = n.min(ch.len()); + dst[off..off + m].copy_from_slice(&ch[..m]); + } + return out; + } let (left, right) = source_pair(item, buf.channel_count()); for (c, dst) in out.iter_mut().enumerate() { let ch = &buf.channels[if c == 0 { left } else { right }]; @@ -431,6 +443,17 @@ pub fn source_51(item: &TrackItem, available: usize) -> Vec { } } +/// The six source channels (L, R, C, LFE, Ls, Rs) a clip on a stereo track folds down: a six-channel +/// pick (Modify ▸ Audio Channels), else all of a six-channel source. `None` plays [`source_pair`] +/// (sources with another channel count have no known 5.1 layout). +fn source_51_fold(item: &TrackItem, available: usize) -> Option> { + match item.source_channels.len() { + 0 if available == 6 => Some((0..6).collect()), + 6.. => Some(source_51(item, available)), + _ => None, + } +} + /// The source channels (left, right) an audio clip plays: its `source_channels` (one = mono on /// both sides; Modify ▸ Audio Channels, Breakout to Mono), else the first two (mono sources on /// both sides). Channels the source doesn't have fall back to the first. From faeca7aeab79dd76b2745a2835cdd24623cb4a88 Mon Sep 17 00:00:00 2001 From: Oskar Date: Sun, 11 Oct 2026 12:58:23 +0200 Subject: [PATCH 217/260] audio: regression test for 5.1 sources on stereo tracks --- crates/render/src/mixer_tests.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/crates/render/src/mixer_tests.rs b/crates/render/src/mixer_tests.rs index b9705e1e3..c5312fb00 100644 --- a/crates/render/src/mixer_tests.rs +++ b/crates/render/src/mixer_tests.rs @@ -638,6 +638,25 @@ fn a_51_track_passes_through_a_51_mix_and_folds_into_stereo() { assert!(close(meters[&crate::mixer::MASTER][5], 0.6, 1e-6)); } +#[test] +fn a_51_source_on_a_stereo_track_folds_down_instead_of_dropping_centre_and_surrounds() { + // the default after New Sequence From Clip: a 5.1 file on a stereo track in a stereo Mix + let mut r = Rig::with_channels(&[chan_dc], 6); + let k = std::f32::consts::FRAC_1_SQRT_2; + let (lo, ro) = (0.1 + k * 0.3 + k * 0.5, 0.2 + k * 0.3 + k * 0.6); + let st = mix_all(&r, 0, 256); + assert_eq!(st.len(), 2); + assert!(close(st[0][9], lo, 1e-6) && close(st[1][9], ro, 1e-6), "BS.775 fold, got {} / {}", st[0][9], st[1][9]); + // a six-channel pick folds the picked channels (here the source reversed: Rs, Ls, LFE, C, R, L) + r.track(0).items[0].source_channels = vec![5, 4, 3, 2, 1, 0]; + let st = mix_all(&r, 0, 256); + assert!(close(st[0][9], 0.6 + k * 0.4 + k * 0.2, 1e-6) && close(st[1][9], 0.5 + k * 0.4 + k * 0.1, 1e-6)); + // a stereo pick still plays exactly those two channels + r.track(0).items[0].source_channels = vec![2, 4]; + let st = mix_all(&r, 0, 256); + assert!(close(st[0][9], 0.3, 1e-6) && close(st[1][9], 0.5, 1e-6)); +} + #[test] fn stereo_track_through_a_51_submix_into_a_stereo_mix() { let mut r = Rig::new(&[chan_dc]); From f10fda5883c6e0f2ee66084aad17bec0499e2ec7 Mon Sep 17 00:00:00 2001 From: whatever-industries <273006022+whatever-industries@users.noreply.github.com> Date: Sun, 11 Oct 2026 04:06:14 -0700 Subject: [PATCH 218/260] release-gate: Fix Extract edges and caption font targeting Handle zero-softness Extract as a hard cutoff in both CPU and GPU paths. The former 0.0001-wide substitute fade introduced grey edge pixels and amplified transfer-function rounding beyond the existing parity tolerance. Retain the positive-softness calculation and add cutoff, inversion and premultiplied-alpha regressions without relaxing the comparison tolerance. Constrain caption font automation targets to the visible part of each row. A partly clipped row could previously expose a click point outside the popup, leaving the selected font unchanged. Preserve the existing font-loading behavior, assets and automation IDs. --- crates/gpu/src/fx.wgsl | 7 ++- crates/gpu/src/fx_tests.rs | 34 ++++++++++++++ crates/render/src/gpufx.rs | 76 ++++++++++++++++++++++++++++--- crates/ui-egui/src/panels/text.rs | 6 ++- 4 files changed, 113 insertions(+), 10 deletions(-) diff --git a/crates/gpu/src/fx.wgsl b/crates/gpu/src/fx.wgsl index 1fe152f83..7d97231f3 100644 --- a/crates/gpu/src/fx.wgsl +++ b/crates/gpu/src/fx.wgsl @@ -383,7 +383,12 @@ fn color_op(op: u32, c: vec3) -> vec3 { case OP_EXTRACT: { let lo = u.p0.x; let hi = u.p0.y; let soft = u.p0.z; let l = linear_to_srgb1(max(luma709(c), 0.0)); - let inside = min(clamp((l - lo) / soft, 0.0, 1.0), clamp((hi - l) / soft, 0.0, 1.0)); + // Hard cutoffs avoid both division by zero and an artificial narrow fade + // that amplifies transfer-function rounding relative to the CPU reference. + var inside = select(0.0, 1.0, l > lo && l < hi); + if soft > 0.0 { + inside = min(clamp((l - lo) / soft, 0.0, 1.0), clamp((hi - l) / soft, 0.0, 1.0)); + } return vec3(select(inside, 1.0 - inside, u.i1.x != 0u)); } case OP_INVERT: { diff --git a/crates/gpu/src/fx_tests.rs b/crates/gpu/src/fx_tests.rs index 6cd1a1d9b..31fc55a5a 100644 --- a/crates/gpu/src/fx_tests.rs +++ b/crates/gpu/src/fx_tests.rs @@ -126,8 +126,14 @@ fn cases() -> Vec<(&'static str, Vec<(&'static str, ParamValue)>, bool)> { ("gamma_correction", vec![("gamma", fl(-3.0))], false), ("levels", vec![("in_black", fl(20.0)), ("in_white", fl(230.0)), ("out_black", fl(10.0)), ("out_white", fl(240.0)), ("gamma", fl(150.0))], false), ("levels", vec![("in_black", fl(200.0)), ("in_white", fl(0.0)), ("gamma", fl(5.0)), ("out_white", fl(-50.0))], true), + ("extract", vec![], true), + ("extract", vec![("invert", ParamValue::Bool(true))], true), ("extract", vec![("black", fl(40.0)), ("white", fl(200.0)), ("softness", fl(30.0))], true), ("extract", vec![("black", fl(90.0)), ("white", fl(160.0)), ("invert", ParamValue::Bool(true))], true), + ("extract", vec![("black", fl(90.0)), ("white", fl(160.0)), ("softness", fl(1.0))], true), + ("extract", vec![("black", fl(40.0)), ("white", fl(200.0)), ("softness", fl(100.0))], true), + ("extract", vec![("black", fl(160.0)), ("white", fl(90.0))], true), + ("extract", vec![("black", fl(90.0)), ("white", fl(90.0))], true), ("invert", vec![("blend", fl(30.0))], false), ("invert", vec![("channel", ParamValue::Choice(1))], false), ("invert", vec![("channel", ParamValue::Choice(2)), ("blend", fl(70.0))], false), @@ -329,6 +335,34 @@ fn gpu_effects_match_cpu_exactly() { assert!(failures.is_empty(), "GPU effects differ from the CPU:\n{}", failures.join("\n")); } +#[test] +fn gpu_extract_zero_softness_has_no_fractional_edge() { + let Some((dev, q)) = device() else { + eprintln!("no GPU adapter; skipping"); + return; + }; + let mut c = GpuCompositor::new(&dev, &q); + let (w, h) = (67, 41); + let px = picture(w, h); + let frame = VideoFrame::rgba_f32(w, h, px.clone()); + let mut masks = Vec::new(); + for invert in [false, true] { + let e = effect("extract", &[("black", fl(90.0)), ("white", fl(160.0)), ("invert", ParamValue::Bool(invert))]); + let op = FxOp::eval(&e, &cx(Tick::ZERO, 1.0), w as usize, h as usize).unwrap(); + let (_, _, gpu) = c.effect_image(&frame, &LayerFx { size: (w, h), decimation: 1, ops: vec![op] }).unwrap(); + for (src, out) in px.chunks_exact(4).zip(gpu.chunks_exact(4)) { + assert_eq!(out[3], src[3], "Extract must preserve alpha"); + assert!(out[0] == 0.0 || out[0] == out[3], "zero softness produced a fractional edge: {out:?}"); + assert_eq!(out[0], out[1]); + assert_eq!(out[0], out[2]); + } + masks.push(gpu); + } + for ((normal, inverted), src) in masks[0].chunks_exact(4).zip(masks[1].chunks_exact(4)).zip(px.chunks_exact(4)) { + assert_eq!(normal[0] + inverted[0], src[3], "inversion must complement the premultiplied mask"); + } +} + /// Chains run in order (and Unsharp keeps its original while blurring, between other blurs). #[test] fn gpu_effect_chains_match_cpu() { diff --git a/crates/render/src/gpufx.rs b/crates/render/src/gpufx.rs index 572d9627d..00b827fd6 100644 --- a/crates/render/src/gpufx.rs +++ b/crates/render/src/gpufx.rs @@ -320,12 +320,17 @@ impl FxOp { g: 100.0 / f(e, "gamma", cx).max(1.0), } } - "extract" => FxOp::Extract { - lo: f(e, "black", cx) / 255.0, - hi: f(e, "white", cx) / 255.0, - soft: f(e, "softness", cx) / 100.0 * 0.2 + 1e-4, - invert: b(e, "invert"), - }, + "extract" => { + let softness = f(e, "softness", cx); + FxOp::Extract { + lo: f(e, "black", cx) / 255.0, + hi: f(e, "white", cx) / 255.0, + // Zero softness is a hard cutoff. A tiny substitute fade amplifies + // CPU/GPU transfer-function rounding and introduces unwanted grey edges. + soft: if softness <= 0.0 { 0.0 } else { softness / 100.0 * 0.2 + 1e-4 }, + invert: b(e, "invert"), + } + } "invert" => FxOp::Invert { channel: choice(e, "channel"), blend: f(e, "blend", cx) / 100.0 }, "posterize" => FxOp::Posterize { n: f(e, "levels", cx).max(2.0) - 1.0 }, "gaussian_blur" | "gaussian_blur_legacy" => { @@ -730,7 +735,12 @@ impl FxOp { let (lo, hi, soft, inv) = (*lo, *hi, *soft, *invert); img.map_rgb(|c, _, _| { let l = linear_to_srgb(luma709(c[0], c[1], c[2]).max(0.0)); - let inside = ((l - lo) / soft).clamp(0.0, 1.0).min(((hi - l) / soft).clamp(0.0, 1.0)); + let inside = if soft <= 0.0 { + // Match the feathered function's limiting value at the cutoffs. + if l > lo && l < hi { 1.0 } else { 0.0 } + } else { + ((l - lo) / soft).clamp(0.0, 1.0).min(((hi - l) / soft).clamp(0.0, 1.0)) + }; let v = if inv { 1.0 - inside } else { inside }; [v, v, v] }); @@ -1086,3 +1096,55 @@ fn resample_cpu(src: &Image, inv: &Affine, rect: [u32; 4]) -> Image { }); out } + +#[cfg(test)] +mod tests { + use super::*; + use filmcraft_project::{ParamValue, find_effect}; + use filmcraft_time::Tick; + + #[test] + fn extract_zero_softness_has_no_fractional_edge() { + 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, + }; + // Both sides of each cutoff, including samples inside the former artificial fade. + let lo = 90.0 / 255.0; + let hi = 160.0 / 255.0; + let samples = [lo - 0.00005, lo + 0.00005, hi - 0.00005, hi + 0.00005]; + for softness in [0.0, -1.0] { + for invert in [false, true] { + for alpha in [1.0, 0.5] { + let mut e = find_effect("extract").unwrap().instance(); + for (key, value) in [("black", 90.0), ("white", 160.0), ("softness", softness)] { + e.params.get_mut(key).unwrap().value = ParamValue::Float(value); + } + e.params.get_mut("invert").unwrap().value = ParamValue::Bool(invert); + let mut img = Image { + w: 4, + h: 1, + px: samples + .iter() + .flat_map(|v| { + let c = filmcraft_color::srgb_to_linear(*v) * alpha; + [c, c, c, alpha] + }) + .collect(), + }; + FxOp::eval(&e, &cx, 4, 1).unwrap().apply(&mut img); + for (pixel, inside) in img.px.chunks_exact(4).zip([false, true, true, false]) { + let value = if inside != invert { alpha } else { 0.0 }; + assert_eq!(pixel, [value, value, value, alpha], "softness={softness}, invert={invert}"); + } + } + } + } + } +} diff --git a/crates/ui-egui/src/panels/text.rs b/crates/ui-egui/src/panels/text.rs index 842000955..9ddf66ac4 100644 --- a/crates/ui-egui/src/panels/text.rs +++ b/crates/ui-egui/src/panels/text.rs @@ -402,8 +402,10 @@ fn style_strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, track_idx: us // 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); + // An edge row may be only partly visible; automation must click its visible portion. + let visible = resp.rect.intersect(ui.clip_rect()); + if visible.is_positive() { + app.auto.add(&format!("text.captions.style.font.option.{f}"), visible, &f); } if resp.clicked() { actions.push(("captions.setStyle".into(), json!({"track": tr.id.0, "font": f}))); From a3445bbc1ac34efafd9095425acecf0fc6dd3094 Mon Sep 17 00:00:00 2001 From: Oskar Date: Sun, 11 Oct 2026 13:23:57 +0200 Subject: [PATCH 219/260] =?UTF-8?q?#1018:=20Scene=20Edit=20Detection?= =?UTF-8?q?=E2=80=A6=20in=20the=20timeline=20clip's=20right-click=20menu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The command and its dialog already existed in the Clip menu, but the clip context menu, where people look for it, didn't offer it. It now sits after Speed/Duration… and opens the same dialog. --- crates/ui-egui/src/panels/timeline.rs | 2 +- crates/ui-egui/tests/clip_dialogs_ui.rs | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index d5766c0bb..07c5fc8f1 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1751,7 +1751,7 @@ pub(crate) const CLIP_MENU: &[&[(&str, &str)]] = &[ ("Multi-Camera", "clip.multicam"), ], &[("Label", "edit.label")], - &[("Speed/Duration…", "clip.speedDuration")], + &[("Speed/Duration…", "clip.speedDuration"), ("Scene Edit Detection…", "clip.sceneEditDetection")], &[ ("Frame Hold Options…", "clip.frameHoldOptions"), ("Add Frame Hold", "clip.frameHold"), diff --git a/crates/ui-egui/tests/clip_dialogs_ui.rs b/crates/ui-egui/tests/clip_dialogs_ui.rs index b60493718..3f1c14a58 100644 --- a/crates/ui-egui/tests/clip_dialogs_ui.rs +++ b/crates/ui-egui/tests/clip_dialogs_ui.rs @@ -289,6 +289,22 @@ fn speed_duration_from_the_clip_menu_opens_the_dialog() { assert_eq!(got.time_interpolation, filmcraft_project::TimeInterpolation::FrameBlending); } +/// #1018: Scene Edit Detection… is in the clip's right-click menu and opens its dialog. +#[test] +fn scene_edit_detection_from_the_clip_menu_opens_the_dialog() { + let mut d = Driver::new(); + let c = d.v1(0); + d.exec("timeline.select", json!({"clips": [c.id.0]})); + d.ok("ui.click", json!({"id": format!("timeline.clip.{}", c.id.0), "button": "right"})); + d.frames(3); + d.click("timeline.clipMenu.clip.sceneEditDetection"); + for id in ["sceneDetect.applyCuts", "sceneDetect.createSubclips", "sceneDetect.generateMarkers", "sceneDetect.ok"] { + assert!(d.has(id), "{id}"); + } + d.click("sceneDetect.cancel"); + assert!(!d.has("sceneDetect.ok")); +} + #[test] fn quitting_with_unsaved_changes_asks_to_save() { let mut d = Driver::new(); From c2fae80f6d5b84f8b9638b902acc8e7d4fb9afd8 Mon Sep 17 00:00:00 2001 From: Oskar Date: Sun, 11 Oct 2026 13:33:19 +0200 Subject: [PATCH 220/260] i18n: es, fr, ja and zh-cn for Premiere's Scene Edit Detection wording Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/es.tsv | 2 ++ crates/ui-egui/src/i18n/fr.tsv | 2 ++ crates/ui-egui/src/i18n/ja.tsv | 2 ++ crates/ui-egui/src/i18n/zh-cn.tsv | 2 ++ 4 files changed, 8 insertions(+) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index cdf8ed2c1..e8d96df76 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2806,6 +2806,8 @@ Apply a cut at each detected cut point Aplicar un corte en cada punto de corte detectado Create a subclip for each detected cut point Crear un subclip para cada punto de corte detectado Generate clip markers at each detected cut point Generar marcadores de clip en cada punto de corte detectado + Create a bin of subclips from each detected cut point Crear una bandeja de subclips a partir de cada punto de corte detectado + Create a clip marker at each detected cut point Crear un marcador de clip en cada punto de corte detectado Sensitivity: Sensibilidad: Normalize Mix Track to: Normalizar pista de mezcla a: Disabled clips Clips desactivados diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 5f17a4f44..9c829c811 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2807,6 +2807,8 @@ 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é + Create a bin of subclips from each detected cut point Créer un chutier de sous-clips à partir de chaque point de coupe détecté + Create a clip marker at each detected cut point Créer un marqueur 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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 2c160f4b6..f5e54cb28 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2795,6 +2795,8 @@ Apply a cut at each detected cut point 検出したカット点ごとにカットを適用 Create a subclip for each detected cut point 検出したカット点ごとにサブクリップを作成 Generate clip markers at each detected cut point 検出したカット点ごとにクリップマーカーを生成 + Create a bin of subclips from each detected cut point 検出したカット点ごとにサブクリップのビンを作成 + Create a clip marker at each detected cut point 検出したカット点ごとにクリップマーカーを作成 Sensitivity: 感度: Normalize Mix Track to: ミックストラックのノーマライズ値: Disabled clips 無効なクリップ diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index 2e6a688b5..66280090f 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -2789,6 +2789,8 @@ Apply a cut at each detected cut point 在每个检测到的切点应用剪切 Create a subclip for each detected cut point 为每个检测到的切点创建子剪辑 Generate clip markers at each detected cut point 在每个检测到的切点生成剪辑标记 + Create a bin of subclips from each detected cut point 从每个检测到的切点创建子剪辑素材箱 + Create a clip marker at each detected cut point 在每个检测到的切点创建剪辑标记 Sensitivity: 灵敏度: Normalize Mix Track to: 将混音轨道标准化至: Disabled clips 已禁用的剪辑 From 18b5db246e46b5cfaa8d05201e8985fa4a3f4f8f Mon Sep 17 00:00:00 2001 From: Oskar Date: Sun, 11 Oct 2026 13:37:49 +0200 Subject: [PATCH 221/260] #1018: Scene Edit Detection options use Premiere's wording "Create a bin of subclips from each detected cut point" and "Create a clip marker at each detected cut point". Drops the old strings from the catalogs. --- crates/engine/src/scene_detect.rs | 6 ++++-- crates/ui-egui/src/i18n/es.tsv | 2 -- crates/ui-egui/src/i18n/fr.tsv | 2 -- crates/ui-egui/src/i18n/ja.tsv | 2 -- crates/ui-egui/src/i18n/zh-cn.tsv | 2 -- crates/ui-egui/src/panels/menu_dialogs.rs | 4 ++-- 6 files changed, 6 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/scene_detect.rs b/crates/engine/src/scene_detect.rs index 59dd68736..3a46ae2a8 100644 --- a/crates/engine/src/scene_detect.rs +++ b/crates/engine/src/scene_detect.rs @@ -9,8 +9,10 @@ //! //! - **Apply a cut at each detected cut point**: the clip (and its linked audio) is cut at the //! first frame of each new shot; -//! - **Create a subclip for each cut**: one subclip per shot, in a new bin " Scenes"; -//! - **Generate clip markers**: a marker on the clip's master clip (media time) at each cut. +//! - **Create a bin of subclips from each detected cut point**: one subclip per shot, in a new +//! bin " Scenes"; +//! - **Create a clip marker at each detected cut point**: a marker on the clip's master clip +//! (media time) at each cut. use std::sync::{Arc, Mutex}; diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index e8d96df76..aa46ff03b 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2804,8 +2804,6 @@ Ignore Audio Ignorar audio Ignore Video Ignorar video Apply a cut at each detected cut point Aplicar un corte en cada punto de corte detectado - Create a subclip for each detected cut point Crear un subclip para cada punto de corte detectado - Generate clip markers at each detected cut point Generar marcadores de clip en cada punto de corte detectado Create a bin of subclips from each detected cut point Crear una bandeja de subclips a partir de cada punto de corte detectado Create a clip marker at each detected cut point Crear un marcador de clip en cada punto de corte detectado Sensitivity: Sensibilidad: diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 9c829c811..09a213bdc 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2805,8 +2805,6 @@ 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é Create a bin of subclips from each detected cut point Créer un chutier de sous-clips à partir de chaque point de coupe détecté Create a clip marker at each detected cut point Créer un marqueur de clip à chaque point de coupe détecté Sensitivity: Sensibilité : diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index f5e54cb28..8c32570ce 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2793,8 +2793,6 @@ Ignore Audio オーディオを無視 Ignore Video ビデオを無視 Apply a cut at each detected cut point 検出したカット点ごとにカットを適用 - Create a subclip for each detected cut point 検出したカット点ごとにサブクリップを作成 - Generate clip markers at each detected cut point 検出したカット点ごとにクリップマーカーを生成 Create a bin of subclips from each detected cut point 検出したカット点ごとにサブクリップのビンを作成 Create a clip marker at each detected cut point 検出したカット点ごとにクリップマーカーを作成 Sensitivity: 感度: diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index 66280090f..5ebd7ede8 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -2787,8 +2787,6 @@ Ignore Audio 忽略音频 Ignore Video 忽略视频 Apply a cut at each detected cut point 在每个检测到的切点应用剪切 - Create a subclip for each detected cut point 为每个检测到的切点创建子剪辑 - Generate clip markers at each detected cut point 在每个检测到的切点生成剪辑标记 Create a bin of subclips from each detected cut point 从每个检测到的切点创建子剪辑素材箱 Create a clip marker at each detected cut point 在每个检测到的切点创建剪辑标记 Sensitivity: 灵敏度: diff --git a/crates/ui-egui/src/panels/menu_dialogs.rs b/crates/ui-egui/src/panels/menu_dialogs.rs index 1d35c21e8..7913489c5 100644 --- a/crates/ui-egui/src/panels/menu_dialogs.rs +++ b/crates/ui-egui/src/panels/menu_dialogs.rs @@ -707,8 +707,8 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { } "clip.sceneEditDetection" => { check(ui, &mut elems, pre, p, "applyCuts", tl!("Apply a cut at each detected cut point")); - check(ui, &mut elems, pre, p, "createSubclips", tl!("Create a subclip for each detected cut point")); - check(ui, &mut elems, pre, p, "generateMarkers", tl!("Generate clip markers at each detected cut point")); + check(ui, &mut elems, pre, p, "createSubclips", tl!("Create a bin of subclips from each detected cut point")); + check(ui, &mut elems, pre, p, "generateMarkers", tl!("Create a clip marker at each detected cut point")); ui.horizontal(|ui| { ui.label(tl!("Sensitivity:")); let mut v = p["sensitivity"].as_f64().unwrap_or(50.0); From 3fd70ff7745b1e4191479f9163112d2ac2d2008f Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 22:44:29 +1100 Subject: [PATCH 222/260] Shift whole-style underlines with baseline_shift. Plain layout() underlines sat on the unshifted baseline while layout_rich() runs already subtracted baseline_shift. Match the two so underline y tracks the glyphs for a full-paragraph style. --- crates/text/src/layout.rs | 3 ++- crates/text/tests/underline_shift.rs | 25 +++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 crates/text/tests/underline_shift.rs diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index c28b8479e..637c4fa5a 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -682,7 +682,8 @@ pub fn layout_rich_uncached(text: &str, style: &TextStyle, runs: &[StyleRun], pa lay.underline_runs.push(*si); } } else if style.underline && pl.content > 0.0 { - lay.underlines.push([x, baseline + pm.underline_pos, x + pl.content, baseline + pm.underline_pos + pm.underline_thickness]); + let y = baseline - style.baseline_shift + pm.underline_pos; + lay.underlines.push([x, y, x + pl.content, y + pm.underline_thickness]); lay.underline_runs.push(0); } lay.lines.push(Line { diff --git a/crates/text/tests/underline_shift.rs b/crates/text/tests/underline_shift.rs new file mode 100644 index 000000000..b8d118f74 --- /dev/null +++ b/crates/text/tests/underline_shift.rs @@ -0,0 +1,25 @@ +//! Whole-style underline must follow `baseline_shift` the same way character-style runs do. +use filmcraft_text::{layout, layout_rich, ParagraphStyle, StyleRun, TextStyle}; + +fn style(shift: f32) -> TextStyle { + TextStyle { size: 100.0, underline: true, baseline_shift: shift, ..Default::default() } +} + +fn underline_y(shift: f32) -> (f32, f32) { + let para = ParagraphStyle::default(); + let s = style(shift); + let plain = layout("Hi", &s, ¶); + let rich = layout_rich("Hi", &s, &[StyleRun { range: 0..2, style: s.clone() }], ¶); + (plain.underlines[0][1], rich.underlines[0][1]) +} + +#[test] +fn whole_style_underline_follows_baseline_shift() { + let (p0, r0) = underline_y(0.0); + for shift in [40.0, -40.0] { + let (plain, rich) = underline_y(shift); + assert!((plain - rich).abs() < 1e-3, "plain {plain} vs rich {rich} at shift {shift}"); + assert!((plain - (p0 - shift)).abs() < 1e-3, "plain underline y {plain} should move with shift {shift}"); + assert!((rich - (r0 - shift)).abs() < 1e-3, "rich underline y {rich} should move with shift {shift}"); + } +} From 66c2e630925d98e7f8607b74cac4d533acb56204 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 22:49:08 +1100 Subject: [PATCH 223/260] Draw underlines for vertical graphic text. The vertical layout path never emitted underline rectangles, so layer and run underlines were a no-op. Place a tall strip to the right of each underlined cell (vertical-rl) and compare horizontal vs vertical "A" at 60px. --- crates/render/tests/underline_direction.rs | 59 ++++++++++++++++++++++ crates/text/src/layout.rs | 33 ++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 crates/render/tests/underline_direction.rs diff --git a/crates/render/tests/underline_direction.rs b/crates/render/tests/underline_direction.rs new file mode 100644 index 000000000..550222d71 --- /dev/null +++ b/crates/render/tests/underline_direction.rs @@ -0,0 +1,59 @@ +//! Horizontal vs vertical graphic text with underline (letter A, 60 px). +use filmcraft_geom::{Affine, Vec2}; +use filmcraft_project::graphic::{new_text_layer, new_vertical_text_layer}; +use filmcraft_project::{EffectInstance, ParamValue}; +use filmcraft_render::graphic_clip::render_graphic_layers; +use filmcraft_render::image::Image; +use filmcraft_time::Tick; + +fn set(e: &mut EffectInstance, k: &str, v: ParamValue) { + e.params.get_mut(k).unwrap_or_else(|| panic!("{k}")).value = v; +} + +fn draw(layer: EffectInstance) -> Image { + let mut img = Image::new(320, 200); + render_graphic_layers(&[layer], Tick::ZERO, (320, 200), &Affine::IDENTITY, &mut img); + img +} + +fn ink_box(img: &Image) -> [usize; 4] { + let mut b: Option<[usize; 4]> = None; + for y in 0..img.h { + for x in 0..img.w { + if img.get(x, y)[3] > 0.4 { + b = Some(match b { + Some(a) => [a[0].min(x), a[1].min(y), a[2].max(x), a[3].max(y)], + None => [x, y, x, y], + }); + } + } + } + b.expect("ink") +} + +fn count(img: &Image) -> usize { + (0..img.h).flat_map(|y| (0..img.w).map(move |x| (x, y))).filter(|&(x, y)| img.get(x, y)[3] > 0.4).count() +} + +#[test] +fn underline_horizontal_vs_vertical_letter_a() { + let pos = Vec2::new(80.0, 100.0); + let mut h = new_text_layer("A", pos, 60.0); + let h_plain = draw(h.clone()); + set(&mut h, "underline", ParamValue::Bool(true)); + let h_ul = draw(h); + let mut v = new_vertical_text_layer("A", Vec2::new(200.0, 100.0), 60.0); + let v_plain = draw(v.clone()); + set(&mut v, "underline", ParamValue::Bool(true)); + let v_ul = draw(v); + + assert!(count(&h_ul) > count(&h_plain) + 20, "horizontal underline adds ink"); + assert!(count(&v_ul) > count(&v_plain) + 20, "vertical underline adds ink"); + + let hb = ink_box(&h_ul); + let hp = ink_box(&h_plain); + let vb = ink_box(&v_ul); + let vp = ink_box(&v_plain); + assert!(hb[3] > hp[3] + 1, "horizontal underline sits below the letter: plain {hp:?} ul {hb:?}"); + assert!(vb[2] > vp[2] + 1, "vertical underline sits to the right of the letter: plain {vp:?} ul {vb:?}"); +} diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index c28b8479e..a0f3878f2 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -724,6 +724,7 @@ fn layout_vertical(text: &str, sty: &Styles, para: &ParagraphStyle, col_w: f32, let row = (pm.ascent + pm.descent).max(style.size * 0.5) + style.tracking * style.size / 1000.0; let flat = ParagraphStyle { vertical: true, ..Default::default() }; lay.vertical = true; + let rich = sty.rich(); let mut base = 0usize; let mut ncols = 0usize; for (c, p) in text.split('\n').enumerate() { @@ -761,6 +762,21 @@ fn layout_vertical(text: &str, sty: &Styles, para: &ParagraphStyle, col_w: f32, } g })); + // Underline runs down the column, to the right of the cell (the "below" side of vertical-rl). + let y_top = baseline - pl.ascent; + if rich { + for (_x0, x1, si) in &pl.underlines { + let st = sty.list[*si as usize]; + let um = fonts::face(sty.prim[*si as usize].face).metrics(st.size); + let x = *x1 + shift - st.baseline_shift + um.underline_pos; + lay.underlines.push([x, y_top, x + um.underline_thickness, y_top + row]); + lay.underline_runs.push(*si); + } + } else if style.underline && pl.content > 0.0 { + let x = shift + pl.left_trim + pl.content + pm.underline_pos; + lay.underlines.push([x, y_top, x + pm.underline_thickness, y_top + row]); + lay.underline_runs.push(0); + } lay.lines.push(Line { range: pl.range, baseline, @@ -998,6 +1014,23 @@ mod tests { assert_eq!(h.lines.len(), 2); } + #[test] + fn vertical_underline_runs_down_the_column() { + let s = TextStyle { underline: true, ..st(60.0) }; + let h = layout("A", &s, &ParagraphStyle::default()); + let v = layout("A", &s, &ParagraphStyle { vertical: true, ..Default::default() }); + assert_eq!(h.underlines.len(), 1); + assert_eq!(v.underlines.len(), 1, "vertical cells get an underline"); + let [hx0, hy0, hx1, hy1] = h.underlines[0]; + let [vx0, vy0, vx1, vy1] = v.underlines[0]; + assert!(hx1 - hx0 > hy1 - hy0, "horizontal underline is a wide strip"); + assert!(vy1 - vy0 > vx1 - vx0, "vertical underline is a tall strip"); + let runs = [StyleRun { range: 0..1, style: s.clone() }]; + let rich = layout_rich("A", &st(60.0), &runs, &ParagraphStyle { vertical: true, ..Default::default() }); + assert_eq!(rich.underlines.len(), 1); + assert_eq!(rich.underline_runs, vec![1]); + } + #[test] fn vertical_cells_keep_combining_marks_with_their_base() { let style = st(32.0); From 9891fd11a49d33f4e02fd89e4e429588e4c38e2b Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 22:59:16 +1100 Subject: [PATCH 224/260] Keep timeline clips when Project Clear removes used media. Clear used to strip every track item that referenced a deleted Project file. Those clips now stay on the sequence as Media Offline, and media.relink still finds the item. Unused media, bins, and generators are still removed. --- crates/engine/src/commands.rs | 39 ++++++++++++++++++--- crates/engine/src/explicit_targets_tests.rs | 6 ++-- crates/engine/src/project_tools_tests.rs | 30 ++++++++++++++-- 3 files changed, 64 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 6609afddf..c91910105 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -2820,20 +2820,48 @@ fn build() -> Vec { } items.sort_by_key(|i| i.0); items.dedup(); + let mut used = std::collections::HashSet::new(); + for it in s.project.sequences() { + if let Some(q) = it.as_sequence() { + for t in q.all_tracks() { + used.extend(t.items.iter().map(|x| x.item)); + } + } + } + let mut gone = Vec::new(); + let mut offlined = Vec::new(); + for i in items { + let keep = used.contains(&i) + && s.project.item(i).and_then(filmcraft_project::ProjectItem::as_media).is_some_and(|m| matches!(m.media, MediaRef::File { .. })); + if keep { + offlined.push(i); + } else { + gone.push(i); + } + } s.edit("Clear", |pr, st| { + for i in &offlined { + if let Some(m) = pr.item_mut(*i).and_then(filmcraft_project::ProjectItem::as_media_mut) { + m.offline = true; + } + } for b in &bins { pr.root.remove_bin(*b); } - for i in &items { + for i in &offlined { + if pr.root.parent_of(*i).is_none() { + pr.root.children.push(filmcraft_project::BinEntry::Item(*i)); + } + } + for i in &gone { pr.items.remove(i); pr.root.remove_item(*i); } - // remove track items that referenced deleted media let seq_ids: Vec = pr.sequences().map(|q| q.id).collect(); for sid in seq_ids { if let Some(q) = pr.sequence_mut(sid) { for t in q.all_tracks_mut() { - t.items.retain(|x| !items.contains(&x.item)); + t.items.retain(|x| !gone.contains(&x.item)); filmcraft_edit::remove_orphan_transitions(t); } } @@ -2841,11 +2869,12 @@ fn build() -> Vec { st.project_selection.clear(); Ok(()) })?; - for i in &items { + for i in gone.iter().chain(&offlined) { s.media.remove(*i); } + crate::relink::refresh(s); s.fix_state(); - Ok(json!({"items": items.len(), "bins": bins.len()})) + Ok(json!({"items": gone.len(), "bins": bins.len(), "offline": offlined.len()})) }), 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) { diff --git a/crates/engine/src/explicit_targets_tests.rs b/crates/engine/src/explicit_targets_tests.rs index c3df07e18..2abbcee12 100644 --- a/crates/engine/src/explicit_targets_tests.rs +++ b/crates/engine/src/explicit_targets_tests.rs @@ -234,15 +234,15 @@ fn project_delete_takes_explicit_bins_and_items_without_a_selection() { assert_eq!(disabled(s.execute("project.delete", json!({}))), "select an item in the Project panel"); // an empty bin, by its id alone let r = s.execute("project.delete", json!({"items": [empty]})).unwrap(); - assert_eq!(r, json!({"items": 0, "bins": 1})); + assert_eq!(r, json!({"items": 0, "bins": 1, "offline": 0})); assert!(s.project.root.find_bin(filmcraft_project::BinId(empty)).is_none()); // a bin with a clip in it, and an item, each by id alone let r = s.execute("project.delete", json!({"items": [outer]})).unwrap(); - assert_eq!(r, json!({"items": 1, "bins": 1})); + assert_eq!(r, json!({"items": 1, "bins": 1, "offline": 0})); assert!(s.project.item(ocean).is_none()); let dunes = item_named(&s, "Desert_Dunes.mp4"); let r = s.execute("project.delete", json!({"items": [dunes.0]})).unwrap(); - assert_eq!(r, json!({"items": 1, "bins": 0})); + assert_eq!(r, json!({"items": 1, "bins": 0, "offline": 0})); assert_eq!(s.project.items.len(), before - 2); assert!(s.state.project_selection.is_empty(), "the selection is left as it was"); assert!(!s.is_enabled("project.delete"), "menu enablement still follows the selection"); diff --git a/crates/engine/src/project_tools_tests.rs b/crates/engine/src/project_tools_tests.rs index e00af7326..fd5ca4041 100644 --- a/crates/engine/src/project_tools_tests.rs +++ b/crates/engine/src/project_tools_tests.rs @@ -469,13 +469,13 @@ fn clear_deletes_a_bin_with_everything_in_it() { select(&mut s); // an empty bin, by its id: gone, nothing else touched let r = s.execute("project.delete", json!({"items": [empty]})).unwrap(); - assert_eq!(r, json!({"items": 0, "bins": 1})); + assert_eq!(r, json!({"items": 0, "bins": 1, "offline": 0})); assert!(s.project.root.find_bin(filmcraft_project::BinId(empty)).is_none()); assert_eq!(s.project.items.len(), before); // a bin holding a sub-bin holding a clip: all three go select(&mut s); let r = s.execute("project.delete", json!({"items": [outer]})).unwrap(); - assert_eq!(r, json!({"items": 1, "bins": 1})); + assert_eq!(r, json!({"items": 1, "bins": 1, "offline": 0})); assert!(s.project.root.find_bin(filmcraft_project::BinId(outer)).is_none()); assert!(s.project.root.find_bin(filmcraft_project::BinId(inner)).is_none()); assert!(s.project.item(ocean).is_none()); @@ -487,7 +487,7 @@ fn clear_deletes_a_bin_with_everything_in_it() { // the clip, its bin and the bin around it all named: each counted once select(&mut s); let r = s.execute("project.delete", json!({"items": [ocean.0, inner, outer]})).unwrap(); - assert_eq!(r, json!({"items": 1, "bins": 1})); + assert_eq!(r, json!({"items": 1, "bins": 1, "offline": 0})); assert_eq!(s.project.items.len(), before - 1); s.execute("edit.undo", json!({})).unwrap(); // the project's own top bin is never cleared @@ -496,3 +496,27 @@ fn clear_deletes_a_bin_with_everything_in_it() { s.execute("project.delete", json!({"items": [root]})).unwrap(); assert_eq!(s.project.items.len(), before); } + +#[test] +fn deleting_media_used_on_the_timeline_makes_it_offline() { + let mut s = Session::default(); + s.execute("file.newProject", json!({"name": "Offline"})).unwrap(); + s.execute("file.newSequence", json!({"name": "Seq", "fps": 25, "video": 1, "audio": 0, "width": 16, "height": 16})).unwrap(); + let item = s.execute("file.newOfflineFile", json!({"name": "Clip", "seconds": 2, "fps": 25, "video": true, "audio": false, "width": 16, "height": 16})).unwrap()["item"] + .as_u64() + .unwrap(); + s.execute("timeline.place", json!({"item": item, "track": "V1", "time": 0})).unwrap(); + let clips_before = s.project.sequences().next().unwrap().as_sequence().unwrap().video_tracks[0].items.len(); + let r = s.execute("project.delete", json!({"items": [item]})).unwrap(); + assert_eq!(r["offline"], 1); + assert_eq!(r["items"], 0); + assert!(s.project.item(ItemId(item)).unwrap().as_media().unwrap().offline); + assert_eq!(s.project.sequences().next().unwrap().as_sequence().unwrap().video_tracks[0].items.len(), clips_before); + let unused = s.execute("file.newOfflineFile", json!({"name": "Spare", "seconds": 1, "fps": 25, "video": true, "audio": false, "width": 16, "height": 16})).unwrap()["item"] + .as_u64() + .unwrap(); + let r = s.execute("project.delete", json!({"items": [unused]})).unwrap(); + assert_eq!(r["items"], 1); + assert_eq!(r["offline"], 0); + assert!(s.project.item(ItemId(unused)).is_none()); +} From 239c46063dff576f719a6e4327272315feb36679 Mon Sep 17 00:00:00 2001 From: Lukas Keller Date: Sun, 11 Oct 2026 16:24:11 +0200 Subject: [PATCH 225/260] docs: German ships the menus (localization parity, ROADMAP) German (de) landed in #630: the localization parity table and the ROADMAP language table now list it as menus only, 371 of 3,498 strings (10.6%), native-reviewed. The localization dimension counts 2 menus-only and 5 none, and 5 of Premiere's 10 languages well and 2 partly. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 5 +++-- docs/localization-parity.md | 9 +++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 16a739209..03c25f8c2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -5,7 +5,7 @@ **Stage: alpha** · next: beta, ~19 points of ready-for-real-work and ~400–700 h away, plus opening Premiere's `.prproj` ([why](docs/target-app-parity.md#stage); passes the [core-workflow gate](docs/roadmap.md#alpha-gate)) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (language table: German ships the menus, #630) · **Target:** Adobe Premiere Pro 2026 (26.5.2) Progress toward parity with Adobe Premiere Pro. This page is the summary; the detail is in [docs/target-app-parity.md](docs/target-app-parity.md) (assessment, methodology, calibration), @@ -85,7 +85,7 @@ Premiere ships 10 interface languages. Measured from our catalogs (3,498 strings | Portuguese (Brazil) | menus only | 9.9% | | Indonesian | none | 0% | | Japanese | partial (whole interface) | 99.7% | -| German | none | 0% | +| German | menus only (native-reviewed) | 10.6% | | Korean | none | 0% | | Vietnamese | none | 0% | @@ -164,6 +164,7 @@ Ranked; detail and "done when" in [docs/roadmap.md](docs/roadmap.md) and [docs/g | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Language table: German ships the menus (#630, 10.6%) | | 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) | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 6124d1c7a..1168d0101 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (German ships the menus, #630) · **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, @@ -46,7 +46,7 @@ whole interface, one PR) at ~4–8 h per language for the catalog plus tests. | 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 | +| German | de | 371 (10.6%) | menus only | Latin | yes (#630) | menus only | 5–9 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 | @@ -54,8 +54,8 @@ 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:** 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 +Simplified Chinese, French), 2 menus-only, 5 none; against Premiere's 10 languages we cover 5 well +and 2 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). @@ -63,4 +63,5 @@ Every language needs native-speaker review (human). | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | German (de) ships the menus (#630): 371 of 3,498 strings (10.6%), native-reviewed; dimension now 2 menus-only, 5 none | | 2026-10-10 | major | Created from ROADMAP.md's "Interface localization" section; catalog coverage measured (es 3,496, ja 3,487, zh-cn 3,485, pt-br 348, uk 351 of 3,498); the 12-language table and script-support gaps added | From 9e04b8b519b8918fc6e2f9df3255eb9b0e917ce8 Mon Sep 17 00:00:00 2001 From: Mara Johnson Date: Sun, 11 Oct 2026 11:31:51 -0400 Subject: [PATCH 226/260] fix(text): Import captions file label uses theme text color (#1072) --- crates/ui-egui/src/panels/text.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/text.rs b/crates/ui-egui/src/panels/text.rs index 842000955..10f20f5ea 100644 --- a/crates/ui-egui/src/panels/text.rs +++ b/crates/ui-egui/src/panels/text.rs @@ -94,7 +94,7 @@ fn captions(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let r = Rect::from_center_size(c + vec2(0.0, 26.0 + i as f32 * 34.0), vec2(200.0, 26.0)); let resp = ui.interact(r, egui::Id::new(id), Sense::click()); ui.painter().rect_filled(r, 13.0, if i == 0 { if resp.hovered() { t.accent_hover } else { t.accent } } else { t.field_bg }); - ui.painter().text(r.center(), Align2::CENTER_CENTER, label, Tokens::semibold(12.0), Color32::WHITE); + ui.painter().text(r.center(), Align2::CENTER_CENTER, label, Tokens::semibold(12.0), if i == 0 { Color32::WHITE } else { t.text }); app.auto.add(id, r, label); if resp.clicked() { actions.push((cmd.into(), json!({}))); From 8e1fb4125855850b52a81dee0808b8e8ef1c0e8e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 20:20:19 +0000 Subject: [PATCH 227/260] #371: split stereo halves from FCP7 XML play on their own side Premiere writes a stereo clip to FCP7 XML as two mono clip items on two tracks (sourcetrack trackindex 1 and 2). The importer already picks channel 0 and channel 1 for them, but left both centred, and a centred one-channel clip plays on both sides. So both channels summed on L and on R: +3 dB per channel for an uncorrelated stereo effect (and its L-R doubled), +6 dB for dual-mono dialogue. Import: a clip that plays one channel of a split two-channel file is panned to that channel's side (channel 0 left, channel 1 right). An Audio Pan filter on the clip item still overrides it. Export: a one-channel clip always writes its Audio Pan, also when it is centred, so a centred Breakout to Mono round-trips centred. Measured with filmcraft-cli (import, export WAV): a split stereo file and a split dual-mono file now come out at 0.00 dB and sample-identical to the source (before: +3.01 dB and +6.02 dB per channel); a mono file on a mono track was and stays at 0 dB. Tests (crates/interchange/tests/fcp7.rs): split_stereo_halves_are_panned_to_their_side and split_stereo_half_keeps_its_audio_pan fail without the import change; centred_mono_breakout_round_trips_centred fails without the export change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012HrAqZmrrDLRpxBJatxvaJ --- crates/interchange/src/fcp7.rs | 12 +++++- crates/interchange/tests/fcp7.rs | 65 +++++++++++++++++++++++++++++++- 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/crates/interchange/src/fcp7.rs b/crates/interchange/src/fcp7.rs index d5de669d2..15919e8eb 100644 --- a/crates/interchange/src/fcp7.rs +++ b/crates/interchange/src/fcp7.rs @@ -516,6 +516,14 @@ impl<'a, 'i> Imp<'a, 'i, '_> { && let Ok(ch) = u16::try_from(idx - 1) { ti.source_channels = vec![ch]; + // The two halves of a split stereo file play on their own side, as the stereo clip did (#371): + // a mono clip is centred (both sides at unity), so the halves would otherwise sum on both + // sides, +3 dB for uncorrelated channels, +6 dB for dual mono. An `audiopan` filter + // (read below) still overrides this. + let stereo = matches!(&self.b.p.item(item).map(|i| &i.kind), Some(ItemKind::Media(m)) if m.info.audio().is_some_and(|a| a.channels == 2)); + if stereo && ch <= 1 { + set_param(&mut ti, "panner", "balance", Param::new(ParamValue::Float(if ch == 0 { -100.0 } else { 100.0 }))); + } } if let Some(mode) = child_text(c, "compositemode") { match BLEND_XML.iter().find(|(n, _)| n.eq_ignore_ascii_case(mode)) { @@ -1159,7 +1167,9 @@ impl Exp<'_, '_> { self.float_param(c, "volume", "level", "Level", 0.0, 3.98109, rate, move |v| db_to_gain(v + g), "level"); self.close_effect(); } - if param_modified(c, "panner", "balance") { + // A one-channel clip says where it is panned even when centred: without Audio Pan the + // importer pans the halves of a split stereo file to their sides (#371). + if param_modified(c, "panner", "balance") || c.source_channels.len() == 1 { self.open_effect("Audio Pan", "audiopan", "audiopan", "audiopan", "audio", true); self.float_param(c, "panner", "balance", "Pan", -1.0, 1.0, rate, |v| v / 100.0, "pan"); self.close_effect(); diff --git a/crates/interchange/tests/fcp7.rs b/crates/interchange/tests/fcp7.rs index 6b2a9414b..aad1d5f0e 100644 --- a/crates/interchange/tests/fcp7.rs +++ b/crates/interchange/tests/fcp7.rs @@ -3,7 +3,7 @@ mod support; -use filmcraft_interchange::{ExportOptions, Format, detect, export, import}; +use filmcraft_interchange::{ExportOptions, Format, Imported, detect, export, import}; use filmcraft_media::Generator; use filmcraft_project::{ItemKind, Keyframe, Label, Marker, MarkerId, MarkerKind, MediaRef, Param, ParamValue, Project, TrackKind, TransitionAlign}; use filmcraft_time::{FrameRate, Tick}; @@ -600,3 +600,66 @@ fn audio_trackindex_1_is_channel_0_only_when_the_file_is_split() { assert!(a1[1].source_channels.is_empty(), "{:?}", a1[1].source_channels); assert_eq!(seq.tracks(TrackKind::Audio)[1].items[0].source_channels, vec![1u16]); } + +/// A Premiere-style stereo file split onto two mono tracks (A1 = `trackindex` 1, A2 = 2). +fn split_stereo_doc(pan_a1: &str) -> String { + format!( + r#" + +S48{RATE} + +"# + ) +} + +fn balances(imp: &Imported) -> Vec { + let seq = imp.project.sequence(only_seq(imp)).unwrap(); + seq.tracks(TrackKind::Audio) + .iter() + .flat_map(|t| &t.items) + .map(|i| i.effect("panner").and_then(|e| e.param("balance")).and_then(|p| p.value.as_f64()).unwrap_or(0.0)) + .collect() +} + +/// #371: the halves of a split stereo file played centred, so both channels summed on both sides +/// (+3 dB for a stereo effect, +6 dB for dual-mono dialogue). They now play on their own side. +#[test] +fn split_stereo_halves_are_panned_to_their_side() { + let (imp, _) = import(split_stereo_doc("").as_bytes(), Format::Fcp7Xml, None).unwrap(); + assert_eq!(balances(&imp), vec![-100.0, 100.0]); +} + +/// An Audio Pan filter on a split half still wins. +#[test] +fn split_stereo_half_keeps_its_audio_pan() { + let pan = r#"Audio Panaudiopanaudiopanaudiopanaudio + panPan-110"#; + let (imp, _) = import(split_stereo_doc(pan).as_bytes(), Format::Fcp7Xml, None).unwrap(); + assert_eq!(balances(&imp), vec![0.0, 100.0]); +} + +/// A centred one-channel clip (Breakout to Mono) round-trips centred: export writes its Audio Pan. +#[test] +fn centred_mono_breakout_round_trips_centred() { + let r = FrameRate::FPS_23_976; + let mut p = Project::new("P"); + let a = media(&mut p, "/m/a.wav", false, true, r); + let s = sequence(&mut p, "t", r, false); + let left = clip(&mut p, s, TrackKind::Audio, 0, a, 0, 24, 0); + let right = clip(&mut p, s, TrackKind::Audio, 1, a, 0, 24, 0); + for (id, ch) in [(left, 0u16), (right, 1u16)] { + let (_, it) = p.sequence_mut(s).unwrap().find_item_mut(id).unwrap(); + it.source_channels = vec![ch]; + } + let (imp, xml, _) = roundtrip(&p, s, Format::Fcp7Xml, &ExportOptions::default()); + assert_eq!(balances(&imp), vec![0.0, 0.0], "{xml}"); +} From 0800a8b0aa4a703ba42172f415b45a61f8d8a5e1 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 11 Oct 2026 11:52:44 -0400 Subject: [PATCH 228/260] ci: refactor workflows to use storytold/craft-common-actions (#1051) * ci: refactor workflows to use reusable artcraft-common-actions * ci: point reusable workflows to storytold/craft-common-actions * ci: pin storytold/craft-common-actions to commit 27a978f and pass secrets explicitly * ci: use @main (unpinned) for craft-common-actions with explanation comment --- .github/workflows/packaging-lint.yml | 56 +--- .github/workflows/release.yml | 485 +-------------------------- .github/workflows/windows-arm64.yml | 72 +--- 3 files changed, 25 insertions(+), 588 deletions(-) diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 960f1ee38..f54b0538b 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -19,57 +19,5 @@ concurrency: jobs: lint: - runs-on: ubuntu-latest - env: - ACTIONLINT_VERSION: 1.7.12 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Install tools - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml - curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \ - | tar -xz -C "$RUNNER_TEMP" actionlint - - name: actionlint - run: | - "$RUNNER_TEMP/actionlint" -color - - name: shellcheck - run: shellcheck packaging/*.sh packaging/*/*.sh - - name: Linux tarball installer - run: python3 packaging/linux/test_install.py - - name: PowerShell - shell: pwsh - run: | - foreach ($f in Get-ChildItem packaging/windows/*.ps1) { - $errors = $null - [System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null - if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 } - Write-Output "ok $($f.Name)" - } - - name: XML, desktop entry, AppStream, Flatpak manifest - run: | - xmllint --noout packaging/windows/filmcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml* - # Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon. - if grep -oE '"$RUNNER_TEMP/ai.storyteller.filmcraft.metainfo.xml" - appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.filmcraft.metainfo.xml" - python3 - <<'EOF' - import yaml - src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.filmcraft.yml")) - bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.filmcraft.bundle.yml")) - for m in (src, bundle): - assert m["id"] == "ai.storyteller.filmcraft" and m["command"] == "filmcraft", m["id"] - for key in ("runtime", "runtime-version", "sdk", "finish-args"): - assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}" - # Both install the app and the CLI/MCP server (#346). - import re - def programs(m): - return {p for c in m["modules"][0]["build-commands"] for p in re.findall(r"/app/bin/([\w.-]+)", c)} - assert programs(src) == programs(bundle) == {"filmcraft", "filmcraft-cli"}, f"Flatpak manifests install {programs(src)} vs {programs(bundle)}" - print("Flatpak manifests ok") - EOF - - name: FreeBSD package layout (stub binaries) - run: bash packaging/freebsd/package.sh --dry-run + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0ebde2f4a..60a30d2b3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,12 +1,3 @@ -# Release pipeline: every push to `release` builds signed installers for every platform and -# creates or updates the draft GitHub Release "FilmCraft v". See docs/releasing.md. -# -# 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 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: @@ -20,469 +11,27 @@ on: default: "" permissions: - contents: read + contents: write concurrency: group: release-${{ github.ref }} - # Never cancel a run mid-notarization or mid-upload; a newer push waits its turn. cancel-in-progress: false -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: 0 - RUST_BACKTRACE: 1 - FILMCRAFT_BUILD_SHA: ${{ github.sha }} - INPUT_VERSION: ${{ inputs.version }} - jobs: - version: - name: version - runs-on: ubuntu-latest - outputs: - version: ${{ steps.v.outputs.version }} - date: ${{ steps.v.outputs.date }} - prerelease: ${{ steps.v.outputs.prerelease }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - id: v - name: Resolve version - run: | - . packaging/env.sh - v="${INPUT_VERSION#v}" - v="${v:-$VERSION}" - if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then - echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1" - exit 1 - fi - case "$v" in *-*) pre=true ;; *) pre=false ;; esac - { - echo "version=$v" - echo "date=$(date -u +%Y-%m-%d)" - echo "prerelease=$pre" - } >>"$GITHUB_OUTPUT" - echo "### FilmCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY" - - macos: - name: macOS universal - needs: version - runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend) - environment: release - 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: aarch64-apple-darwin, x86_64-apple-darwin - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - key: release-macos-universal - - name: Apply version override - if: inputs.version != '' - run: cargo xtask version set "$FILMCRAFT_VERSION" - - name: Import signing certificate - env: - APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} - run: packaging/macos/import-cert.sh - - name: Build, sign, notarize - env: - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - run: packaging/macos/package.sh --arch universal - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-macos - path: dist/release/* - if-no-files-found: error - - name: Remove temporary keychain - if: always() - run: | - if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi - - windows: - name: Windows ${{ matrix.arch }} - needs: version - runs-on: windows-latest - environment: release - strategy: - fail-fast: false - matrix: - include: - - { arch: x64, target: x86_64-pc-windows-msvc } - - { arch: x86, target: i686-pc-windows-msvc } - # Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above. - # .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware. - - { arch: arm64, target: aarch64-pc-windows-msvc } - 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: ${{ matrix.target }} - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - key: release-windows-${{ matrix.arch }} - - name: Apply version override - if: inputs.version != '' - shell: bash - run: cargo xtask version set "$FILMCRAFT_VERSION" - - name: Install WiX - shell: pwsh - run: dotnet tool install --global wix --version 5.0.2 - - name: Build, sign, package - shell: pwsh - env: - WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} - WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} - AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} - AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} - AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }} - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-windows-${{ matrix.arch }} - path: dist/release/* - if-no-files-found: error - - linux: - name: Linux ${{ matrix.arch }} - needs: version - # The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+, - # Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md. - runs-on: ${{ matrix.runner }} - # No `environment: release`: this job signs nothing, so it gets no release secrets. - strategy: - fail-fast: false - matrix: - include: - - { arch: x86_64, runner: ubuntu-22.04, deb: amd64 } - - { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 } - env: - FILMCRAFT_VERSION: ${{ needs.version.outputs.version }} - FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - NFPM_VERSION: 2.47.0 - 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 - - name: Install build and packaging tools - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev libasound2-dev \ - desktop-file-utils appstream python3-yaml zsync - curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \ - "https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb" - sudo dpkg -i "$RUNNER_TEMP/nfpm.deb" - - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - key: release-linux-${{ matrix.arch }} - - name: Apply version override - if: inputs.version != '' - run: cargo xtask version set "$FILMCRAFT_VERSION" - - name: Build and package (AppImage, deb, rpm, tar.gz) - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - run: packaging/linux/package.sh - - name: Check the packages - run: | - dpkg-deb --info dist/release/*.deb - dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/filmcraft$|metainfo|applications' - ldd target/release/filmcraft - python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.filmcraft.yml')); assert m['id'] == 'ai.storyteller.filmcraft'" - for appimage in dist/release/*.AppImage; do - chmod +x "$appimage" - APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version - # Update information is embedded, and the .zsync for delta updates sits beside it. - "$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|' - test -s "$appimage.zsync" - done - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-linux-${{ matrix.arch }} - 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] - # Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust - # build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships. - runs-on: ${{ matrix.runner }} - # No `environment: release`: this job signs nothing, so it gets no release secrets. - strategy: - fail-fast: false - matrix: - include: - - { arch: x86_64, runner: ubuntu-24.04 } - - { arch: aarch64, runner: ubuntu-24.04-arm } - env: - FILMCRAFT_VERSION: ${{ needs.version.outputs.version }} - FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - name: release-linux-${{ matrix.arch }} - path: dist/linux - - name: Install flatpak-builder - run: | - sudo apt-get update - # librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's - # `appstreamcli compose`, which renders the scalable icon and fails without it. - sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common - # Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs. - sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - - name: Build the Flatpak bundle - run: packaging/linux/flatpak-bundle.sh "dist/linux/filmcraft-$FILMCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz" - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-flatpak-${{ matrix.arch }} - path: dist/release/*.flatpak - if-no-files-found: error - - freebsd: - name: FreeBSD x86_64 - needs: version - # GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM, the same image and packages as - # .github/workflows/freebsd.yml. - runs-on: ubuntu-latest - timeout-minutes: 180 - # 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 - - name: Build and package in a FreeBSD VM (tar.gz) - # vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build). - uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9 - with: - release: "14.3" - usesh: true - cpu: 4 - mem: 12288 - # Passed into the VM by name; never templated into the script below. - envs: "FILMCRAFT_VERSION FILMCRAFT_BUILD_DATE FILMCRAFT_BUILD_SHA INPUT_VERSION" - # The FreeBSD CI packages (.github/workflows/freebsd.yml), plus bash for the script. - prepare: | - pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable - run: | - set -e - . "$HOME/.cargo/env" - git config --global --add safe.directory "$PWD" - # Build outside the checkout so only dist/ is copied back out of the VM (copying the - # target dir back fails on large builds). 4 jobs: more runs the VM out of memory. - export CARGO_TARGET_DIR=/var/tmp/filmcraft-target CARGO_BUILD_JOBS=4 - if [ -n "$INPUT_VERSION" ]; then cargo xtask version set "$FILMCRAFT_VERSION"; fi - # craft-fonts was checked out next to the sources and synced into the VM with them. - export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1 - bash packaging/freebsd/package.sh - tar -tzf dist/release/filmcraft-*-freebsd-x86_64.tar.gz \ - | grep -E '/bin/filmcraft$|/bin/filmcraft-cli$|/share/applications/ai\.storyteller\.filmcraft\.desktop$|/share/doc/filmcraft/LICENSE-MIT$' - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-freebsd-x86_64 - path: dist/release/* - if-no-files-found: error - - web: - name: Web (wasm) - needs: version - runs-on: ubuntu-latest - # 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 }} - WASM_BINDGEN_VERSION: 0.2.129 - 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: wasm32-unknown-unknown - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - key: release-web - - name: Install wasm-bindgen CLI - run: | - # Must match the wasm-bindgen crate version exactly (WASM_BINDGEN in xtask/src/main.rs). - if ! { command -v wasm-bindgen >/dev/null && wasm-bindgen --version | grep -q "$WASM_BINDGEN_VERSION"; }; then - cargo install wasm-bindgen-cli --version "$WASM_BINDGEN_VERSION" --locked - fi - wasm-bindgen --version - - name: Apply version override - if: inputs.version != '' - run: cargo xtask version set "$FILMCRAFT_VERSION" - - name: Build and zip - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - run: packaging/web/package.sh - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-web - path: dist/release/* - if-no-files-found: error - release: - name: Draft GitHub Release - needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web] - runs-on: ubuntu-latest - environment: release - permissions: - contents: write - env: - VERSION: ${{ needs.version.outputs.version }} - PRERELEASE: ${{ needs.version.outputs.prerelease }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GH_REPO: ${{ github.repository }} - steps: - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - pattern: release-* - path: artifacts - merge-multiple: true - - name: Checksums - working-directory: artifacts - run: | - sha256sum -- * | tee SHA256SUMS.txt - - name: Create or update the draft release - working-directory: artifacts - run: | - tag="v$VERSION" - title="FilmCraft v$VERSION" - pre=() - if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi - if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then - if [ "$draft" != "true" ]; then - echo "::error::Release $tag is already published. Bump the version (cargo xtask version set) before pushing to release." - exit 1 - fi - echo "Updating draft $tag: replacing its assets" - gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do - gh release delete-asset "$tag" "$asset" --yes - done - gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE" - gh release upload "$tag" --clobber -- * - else - echo "Creating draft $tag" - gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- * - fi - { - echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:" - echo - sed 's/^/ /' SHA256SUMS.txt - } >>"$GITHUB_STEP_SUMMARY" + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/release.yml@main + with: + app-name: filmcraft + app-title: FilmCraft + craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + version: ${{ inputs.version }} + secrets: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index ccb475e58..7666423a1 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -26,70 +26,10 @@ concurrency: permissions: contents: read -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: 0 - jobs: - package: - name: package (cross-compiled on x64) - runs-on: windows-latest - timeout-minutes: 90 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable - with: - targets: aarch64-pc-windows-msvc - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - key: windows-arm64-package - - name: Install WiX - shell: pwsh - run: dotnet tool install --global wix --version 5.0.2 - - name: Build and package (unsigned) - shell: pwsh - env: - DIST: ${{ github.workspace }}\dist\arm64 - run: ./packaging/windows/package.ps1 -Arch arm64 - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: filmcraft-windows-arm64 - path: dist/arm64/* - if-no-files-found: error - retention-days: 7 - - install: - name: install and run on ARM64 - needs: package - runs-on: windows-11-arm - timeout-minutes: 30 - steps: - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - name: filmcraft-windows-arm64 - path: dist - - name: Install the MSI, check it, run it, uninstall it - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - $msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName - $p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru - if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" } - $dir = Join-Path $env:ProgramFiles 'FilmCraft' - foreach ($exe in 'filmcraft.exe', 'filmcraft-cli.exe') { - $path = Join-Path $dir $exe - if (-not (Test-Path $path)) { throw "$exe not installed in $dir" } - $bytes = [System.IO.File]::ReadAllBytes($path) - $machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4) - if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" } - Write-Output "ok $path (ARM64)" - } - # The installed CLI runs natively on ARM64. - $cli = Join-Path $dir 'filmcraft-cli.exe' - $out = & $cli --version 2>&1 | Out-String - if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "filmcraft-cli --version exited $LASTEXITCODE" } - Write-Output "ok filmcraft-cli --version: $($out.Trim())" - $p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru - if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" } - if (Test-Path (Join-Path $dir 'filmcraft.exe')) { throw 'uninstall left filmcraft.exe behind' } - Write-Output 'ok uninstalled' + windows-arm64: + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main + with: + app-name: filmcraft + app-dir: FilmCraft From d4e4512e7073f3033851826e7e8c87d9a23fa66f Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 18:38:54 +0200 Subject: [PATCH 229/260] #1091: Home / End and other jumps scroll the Timeline to the playhead Problem: the Timeline followed the playhead only while playing, so a jump while stopped (Home, End, edit points, markers) left the playhead off screen. Fix: when the playhead of the shown sequence changes while stopped and falls outside the view, the view scrolls to it with the page-scroll margin. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/timeline.rs | 49 ++++++++++++++++++++++ crates/ui-egui/src/state.rs | 4 ++ crates/ui-egui/tests/timeline_scroll_ui.rs | 16 +++++++ 3 files changed, 69 insertions(+) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index d5766c0bb..40f21962a 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -232,6 +232,16 @@ pub fn max_scroll(sequence_seconds: f64, width: f32, pps: f64) -> f64 { (timeline_extent(sequence_seconds) - width as f64 / pps).max(0.0) } +/// Where the left edge goes so the playhead at `ph` seconds shows in a view `width` points wide +/// (the page-scroll margin from the left), or `None` when it is in view already. +pub fn scroll_to_reveal(ph: f64, scroll: f64, pps: f64, width: f64) -> Option { + if !ph.is_finite() || !scroll.is_finite() || !(pps > 0.0 && pps.is_finite()) { + return None; + } + let px = (ph - scroll) * pps; + (px < 0.0 || px > width).then(|| (ph - width * 0.05 / pps).max(0.0)) +} + /// Zoom about a time (keeps it under the same screen x). pub fn zoom_about(v: &mut TimelineView, factor: f64, anchor_secs: f64, width: f32) { let old = v.target_pps; @@ -323,6 +333,22 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { v.scroll = v.target_scroll; } } + // A jump while stopped (Home, End, edit points, markers) brings the playhead into view. + { + let v = &mut app.ui.timeline; + let ph = app.session.playhead().seconds(); + // another sequence's tab has its own playhead and keeps its own view + let moved = v.seen_playhead.is_some_and(|(id, seen)| id == seq_id && seen != ph); + v.seen_playhead = Some((seq_id, ph)); + if moved + && !app.playback.playing + && !v.fit_pending + && let Some(s) = scroll_to_reveal(ph, v.target_scroll, v.target_pps, content.width() as f64) + { + v.target_scroll = s; + v.scroll = s; + } + } // The view stays inside the Timeline's extent, however it was moved (wheel, scroll bar, zoom, // a script): scrolled past it, nothing is in view and the scroll bar has nowhere to show it. { @@ -2823,6 +2849,29 @@ mod trim_preview_tests { } } +#[cfg(test)] +mod reveal_tests { + use super::scroll_to_reveal; + + #[test] + fn home_scrolls_back_to_the_start() { + assert_eq!(scroll_to_reveal(0.0, 120.0, 40.0, 800.0), Some(0.0)); + } + + #[test] + fn end_scrolls_forward_to_the_playhead() { + let s = scroll_to_reveal(300.0, 0.0, 40.0, 800.0).unwrap(); + assert!((0.0..=300.0).contains(&s) && (300.0 - s) * 40.0 <= 800.0); + } + + #[test] + fn a_visible_or_hostile_playhead_leaves_the_view() { + assert_eq!(scroll_to_reveal(5.0, 0.0, 40.0, 800.0), None); + assert_eq!(scroll_to_reveal(f64::NAN, 0.0, 40.0, 800.0), None); + assert_eq!(scroll_to_reveal(5.0, 0.0, 0.0, 800.0), None); + } +} + #[cfg(test)] mod waveform_tests { use super::waveform_display_gain; diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 2f780e118..3a6f91842 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -197,6 +197,9 @@ pub struct TimelineView { /// Follow playhead during playback (page scroll). pub follow: bool, pub fit_pending: bool, + /// The playhead the view last saw: a jump while stopped (Home, End, edit points) scrolls to it. + #[serde(skip)] + pub seen_playhead: Option<(filmcraft_project::ItemId, f64)>, /// The zoom a fit gave an empty sequence (which fits its 1 s minimum): the first clip to arrive /// fits again, unless the zoom was changed in between. #[serde(skip)] @@ -232,6 +235,7 @@ impl Default for TimelineView { show_waveforms: true, follow: true, fit_pending: true, + seen_playhead: None, fit_empty: None, track_lanes: Default::default(), } diff --git a/crates/ui-egui/tests/timeline_scroll_ui.rs b/crates/ui-egui/tests/timeline_scroll_ui.rs index af20a815f..c20a9c23d 100644 --- a/crates/ui-egui/tests/timeline_scroll_ui.rs +++ b/crates/ui-egui/tests/timeline_scroll_ui.rs @@ -285,3 +285,19 @@ fn continuous_video_thumbnails_ask_for_frames_across_each_clip() { assert_eq!(v["ok"], json!(false), "{v}"); assert!(!d.app().ui.timeline.show_thumbnails); } + +#[test] +fn home_and_end_bring_the_playhead_into_view() { + let mut d = Driver::demo(); + d.ok("ui.set", json!({"timeline": {"pps": 400.0, "scroll": 3.0}})); + d.frames(3); + d.exec("playhead.start", json!({})); + d.frames(3); + assert_eq!(d.app().ui.timeline.target_scroll, 0.0, "Home scrolls back to the start"); + d.exec("playhead.end", json!({})); + d.frames(3); + let end = d.app().session.playhead().seconds(); + let v = &d.app().ui.timeline; + let width = 1600.0 / v.pps; + assert!(v.target_scroll > 3.0 && v.target_scroll <= end && end - v.target_scroll < width, "End shows the playhead: scroll {} end {end}", v.target_scroll); +} From b5306608cc727887e4010f932e05f77faac6603a Mon Sep 17 00:00:00 2001 From: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Date: Sun, 11 Oct 2026 18:47:08 +0200 Subject: [PATCH 230/260] fix(effects): keep a setParam keyframe outside the clip at its time effects.setParam clamped an explicit `time` into the clip, so a keyframe past the end replaced one sitting on the last frame and a ramp across a cut was compressed to finish at the clip end. The same happened before the start. An explicit time now keeps its keyframe where it was asked, outside the clip if need be, as effects.moveKeyframe already allows; only the playhead fallback stays pinned to the clip. Fixes #1055 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands.rs | 8 ++++-- crates/engine/src/tests.rs | 53 +++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 6609afddf..d7c7102a2 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -2600,7 +2600,7 @@ fn build() -> Vec { "Set Effect Parameter", [], None, - r##"{"clip":id,"effect":"motion"|index,"param":str,"mask":n?,"value":num|[x,y]|"#rrggbb"|bool|path|"option name" (a choice takes its index or its name),"time":ticks? (where the keyframe goes when the parameter is animated; a parameter that is not animated gets a new static value whatever the time),"keyframe":bool? (true: write a keyframe at `time` and animate the parameter),"merge":bool?,"begin":bool?}"##, + r##"{"clip":id,"effect":"motion"|index,"param":str,"mask":n?,"value":num|[x,y]|"#rrggbb"|bool|path|"option name" (a choice takes its index or its name),"time":ticks? (where the keyframe goes when the parameter is animated, kept there even outside the clip; default: the playhead, pinned to the clip; a parameter that is not animated gets a new static value whatever the time),"keyframe":bool? (true: write a keyframe at `time` and animate the parameter),"merge":bool?,"begin":bool?}"##, has_seq, |s, p| { let c = clip_p(p, "clip").ok_or_else(|| bad("effects.setParam", "need `clip`"))?; @@ -2608,8 +2608,10 @@ fn build() -> Vec { let val = p.get("value").cloned().ok_or_else(|| bad("effects.setParam", "need `value`"))?; let eff = p.get("effect").cloned().unwrap_or(json!("motion")); let keyframe = bool_p(p, "keyframe").unwrap_or(false); + // an explicit `time` outside the clip keeps its keyframe there, before the start as after + // the end, so a ramp across a cut keeps its slope (#1055); the playhead is pinned to the clip + let at = time_p(s, p, ""); let ph = s.playhead(); - let tl = time_p(s, p, "").unwrap_or(ph); let pq = p.clone(); // `merge`: a drag of one parameter is one undo step (#201); `begin` starts a new one let merge = bool_p(p, "merge").unwrap_or(false).then(|| format!("setParam:{}:{eff}:{pid}:{}", c.0, p.get("mask").unwrap_or(&Value::Null))); @@ -2618,7 +2620,7 @@ fn build() -> Vec { } let keyframes = s.edit_sequence_as("Change Effect Parameter", merge.as_deref(), |q, _, _| { let (_, it) = q.find_item_mut(c).ok_or(filmcraft_edit::EditError::NoItem(c))?; - let mt = it.source_time_at(tl.clamp(it.start, (it.end() - Tick(1)).max(it.start))); + let mt = it.source_time_at(at.unwrap_or_else(|| 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), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9852734d7..5366a1dd5 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -381,6 +381,59 @@ fn set_param_writes_a_keyframe_when_asked_or_animated() { assert_eq!(s.execute("effects.setParam", odd).unwrap()["keyframes"], 2); } +/// A keyframe set with a `time` outside the clip stays at that time (#1055), before the start as +/// after the end: a ramp that spans a cut shows its true slice, and a clip-end keyframe is not +/// overwritten by a later one. Only the playhead fallback is pinned to the clip. +#[test] +fn set_param_keeps_a_keyframe_outside_the_clip_at_its_time() { + let mut s = demo(); + let it = s.active_sequence().unwrap().video_tracks[0].items[1].clone(); + let (c, start, end) = (it.id.0, it.start, it.end()); + let opacity = |s: &Session| { + let it = s.active_sequence().unwrap().find_item(filmcraft_project::ClipId(c)).unwrap().1; + let p = &it.effect("opacity").unwrap().params["opacity"]; + (p.keyframes.iter().map(|k| (k.time, k.value.as_f64().unwrap())).collect::>(), it.clone()) + }; + let set = |value: f64, time: Tick| json!({"clip": c, "effect": "opacity", "param": "opacity", "value": value, "time": time.0, "keyframe": true}); + let len = end - start; + // Case A: a keyframe exactly at the clip end, then one past it, gives three keyframes + s.execute("effects.setParam", set(100.0, start)).unwrap(); + s.execute("effects.setParam", set(100.0, end)).unwrap(); + let r = s.execute("effects.setParam", set(20.0, end + len)).unwrap(); + assert_eq!(r["keyframes"], 3, "{r}"); + let (keys, it) = opacity(&s); + assert_eq!(keys, vec![(it.source_time_at(start), 100.0), (it.source_time_at(end), 100.0), (it.source_time_at(end + len), 20.0)]); + // Case B: a ramp past the end is not compressed; halfway through the clip is a quarter of the way down + s.undo(); + s.undo(); + let r = s.execute("effects.setParam", set(0.0, end + len)).unwrap(); + assert_eq!(r["keyframes"], 2, "{r}"); + let (_, it) = opacity(&s); + let p = it.effect("opacity").unwrap().params["opacity"].clone(); + let mid = start + Tick(len.0 / 2); + assert!((p.f64_at(it.source_time_at(mid)) - 75.0).abs() < 0.5, "{}", p.f64_at(it.source_time_at(mid))); + // before the start the same holds: the keyframe keeps its time and the ramp its slope + s.undo(); + s.undo(); + s.execute("effects.setParam", set(0.0, start - len)).unwrap(); + s.execute("effects.setParam", set(100.0, end)).unwrap(); + let (keys, it) = opacity(&s); + assert_eq!(keys, vec![(it.source_time_at(start - len), 0.0), (it.source_time_at(end), 100.0)]); + let p = it.effect("opacity").unwrap().params["opacity"].clone(); + assert!((p.f64_at(it.source_time_at(start)) - 50.0).abs() < 0.5, "{}", p.f64_at(it.source_time_at(start))); + // hostile times saturate instead of overflowing + for t in [i64::MIN, i64::MAX] { + s.execute("effects.setParam", json!({"clip": c, "effect": "opacity", "param": "opacity", "value": 50.0, "time": t})).unwrap(); + } + // with no `time`, a playhead past the clip still lands on its last frame + s.undo(); + s.undo(); + s.execute("playhead.set", json!({"time": (end + len).0})).unwrap(); + s.execute("effects.setParam", json!({"clip": c, "effect": "opacity", "param": "opacity", "value": 40.0})).unwrap(); + let (keys, it) = opacity(&s); + assert!(keys.contains(&(it.source_time_at(end - Tick(1)), 40.0)), "{keys:?}"); +} + /// A choice parameter takes its option name as well as its index, as transition parameters do /// (`effects.list {"detail": true}` lists the names). #[test] From 7a3d00319493c05b215a91bc96a46528bb97fc69 Mon Sep 17 00:00:00 2001 From: Ikki Nishihara Date: Mon, 12 Oct 2026 01:55:24 +0900 Subject: [PATCH 231/260] =?UTF-8?q?fix:=20=E3=82=B5=E3=83=96=E3=82=AF?= =?UTF-8?q?=E3=83=AA=E3=83=83=E3=83=97=E9=85=8D=E7=BD=AE=E3=81=A7=E8=A6=AA?= =?UTF-8?q?=E3=81=AE=E9=9F=B3=E5=A3=B0=E3=82=92=E4=BF=9D=E6=8C=81=20#913?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/engine/src/commands.rs | 16 +-- crates/engine/src/sequence_tools.rs | 7 +- crates/engine/src/source_placement_tests.rs | 103 +++++++++++++++++++- crates/project/src/lib.rs | 23 ++++- 4 files changed, 135 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 6609afddf..33b2b1040 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -681,8 +681,9 @@ pub(crate) fn place_item( if s.state.sequences_as_clips && pi.as_sequence().is_some_and(|q| q.multicam.is_none()) { return crate::sequence_tools::place_sequence_clips(s, item, range, at, vdest, adest, insert, label); } - let has_v = pi.has_video() && vdest.is_some(); - let has_a = pi.has_audio() && adest.is_some(); + let (video, audio) = s.project.source_streams(item); + let has_v = video && vdest.is_some(); + let has_a = audio && adest.is_some(); if !has_v && !has_a { return Err(EngineError::Other("no destination track for this media (check source patching)".into())); } @@ -724,7 +725,7 @@ pub(crate) fn place_item( let mut a = p.make_track_item(item, TrackKind::Audio, aat, arange, rate).ok_or_else(|| bad(label, "bad item"))?; // Modify ▸ Audio Channels with several audio clips: one per clip, on the tracks below let clips: Vec> = - p.item(item).and_then(|i| i.as_media()).and_then(|m| m.interpret.audio_channels.as_ref()).map(|m| m.clips.clone()).unwrap_or_default(); + p.resolve_media(item).and_then(|(_, m, _)| m.interpret.audio_channels.as_ref()).map(|m| m.clips.clone()).unwrap_or_default(); let streams = p.resolve_media(item).map_or(1, |(_, m, _)| m.info.audio_streams.len()); let specs = audio_placement_specs(&clips, streams); let first = p.sequence(seq_id).and_then(|q| q.audio_tracks.iter().position(|t| t.id == adest)).unwrap_or(0); @@ -2203,6 +2204,7 @@ fn build() -> Vec { let at = time_p(s, p, "").unwrap_or(s.playhead()); let tg = s.targeting(); let pi = s.project.item(item).ok_or_else(|| bad("timeline.place", "no such item"))?; + let (has_video, has_audio) = s.project.source_streams(item); let is_audio = |t: TrackId| s.active_sequence().is_some_and(|q| q.audio_tracks.iter().any(|x| x.id == t)); // `track` is where the picture goes and `audioTrack` where the sound goes. A caller that // names an audio track as `track` asks for the sound alone, on that track. @@ -2211,21 +2213,21 @@ fn build() -> Vec { return Err(bad("timeline.place", "`track` names an audio track and `audioTrack` is given as well: name the sound's track once")); } (_, Some(t)) if !is_audio(t) => return Err(bad("timeline.place", "`audioTrack` names a video track")), - (Some(t), None) if is_audio(t) && !pi.has_audio() => { + (Some(t), None) if is_audio(t) && !has_audio => { return Err(bad("timeline.place", "`track` names an audio track and this item has no sound")); } (Some(t), None) if is_audio(t) => (None, Some(t)), - (Some(_), None) if !pi.has_video() => { + (Some(_), None) if !has_video => { return Err(bad("timeline.place", "`track` names a video track and this item has no picture: name an audio track")); } (v, a) => (v.or(tg.video_dest), a.or(tg.audio_dest)), }; let v = v.filter(|_| video); let a = a.filter(|_| audio); - if p.get("video").is_some() && video && pi.has_video() && v.is_none() { + if p.get("video").is_some() && video && has_video && v.is_none() { return Err(bad("timeline.place", "no video destination: add or enable a video track")); } - if p.get("audio").is_some() && audio && pi.has_audio() && a.is_none() { + if p.get("audio").is_some() && audio && has_audio && a.is_none() { return Err(bad("timeline.place", "no audio destination: add or enable an audio track")); } let full = full_duration(s, pi); diff --git a/crates/engine/src/sequence_tools.rs b/crates/engine/src/sequence_tools.rs index dc6c57953..a6e508dd1 100644 --- a/crates/engine/src/sequence_tools.rs +++ b/crates/engine/src/sequence_tools.rs @@ -735,11 +735,12 @@ pub(crate) fn wants_new_track(p: &Value) -> bool { 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"))?; + s.project.item(item).ok_or_else(|| bad(cmd, "no such item"))?; + let (video, audio) = s.project.source_streams(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"); + let new_v = is_new("track") && video && enabled("video"); + let new_a = is_new("audioTrack") && 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")); } diff --git a/crates/engine/src/source_placement_tests.rs b/crates/engine/src/source_placement_tests.rs index 0fb56556c..8e85f7f2f 100644 --- a/crates/engine/src/source_placement_tests.rs +++ b/crates/engine/src/source_placement_tests.rs @@ -1,5 +1,5 @@ use super::*; -use filmcraft_project::ItemId; +use filmcraft_project::{ItemId, ItemKind}; use filmcraft_time::{Tick, TimeRange}; use serde_json::json; @@ -83,6 +83,107 @@ fn audio_only_source_drops_only_sound() { assert_eq!(s.active_sequence().unwrap().audio_tracks[1].items.len(), 1); } +fn subclip_session(video: bool, audio: bool) -> (Session, ItemId) { + let mut s = Session::default(); + s.execute("file.newSequence", json!({"name":"Subclip test","fps":24})).unwrap(); + let parent = + s.execute("file.newOfflineFile", json!({"name":"Parent","video":video,"audio":audio,"fps":24,"seconds":10})).unwrap()["item"].as_u64().unwrap(); + let sub = s.execute("clip.makeSubclip", json!({"item":parent,"startFrame":10,"endFrame":60})).unwrap()["item"].as_u64().unwrap(); + (s, ItemId(sub)) +} + +fn assert_subclip_placement(s: &Session, item: ItemId, expected: (usize, usize)) { + assert_eq!(counts(s), expected); + let q = s.active_sequence().unwrap(); + let ItemKind::Subclip { range, .. } = &s.project.item(item).unwrap().kind else { panic!("expected a subclip") }; + for clip in q.all_tracks().flat_map(|t| t.items.iter()) { + assert_eq!(clip.item, item); + assert_eq!(clip.source_in, range.start); + assert_eq!(clip.duration, q.settings.frame_rate.snap_nearest(range.duration)); + } + if expected == (1, 1) { + let video = &q.video_tracks[0].items[0]; + let audio = &q.audio_tracks[0].items[0]; + assert!(video.link.is_some()); + assert_eq!(video.link, audio.link); + } +} + +#[test] +fn subclip_placement_keeps_parent_streams_range_and_history() { + for (video, audio, expected) in [(true, true, (1, 1)), (false, true, (0, 1)), (true, false, (1, 0))] { + let (mut s, sub) = subclip_session(video, audio); + let before = s.project.to_json(); + s.execute("timeline.place", json!({"item":sub.0,"track":"V1","audioTrack":"A1","time":0,"video":true,"audio":true})).unwrap(); + assert_subclip_placement(&s, sub, expected); + let placed = s.project.to_json(); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.project.to_json(), before); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(s.project.to_json(), placed); + s.project = std::sync::Arc::new(Project::from_json(&placed).unwrap()); + assert_subclip_placement(&s, sub, expected); + } +} + +#[test] +fn subclip_audio_can_be_placed_on_an_audio_track() { + for video in [false, true] { + let (mut s, sub) = subclip_session(video, true); + s.execute("timeline.place", json!({"item":sub.0,"track":"A1","time":0,"video":false,"audio":true})).unwrap(); + assert_subclip_placement(&s, sub, (0, 1)); + } + let (mut s, sub) = subclip_session(true, false); + let before = s.project.to_json(); + assert!(s.execute("timeline.place", json!({"item":sub.0,"track":"A1","time":0,"video":false,"audio":true})).is_err()); + assert_eq!(s.project.to_json(), before); +} + +#[test] +fn source_insert_and_overwrite_keep_subclip_sound() { + for command in ["source.insert", "source.overwrite"] { + for (video, audio, expected) in [(true, true, (1, 1)), (false, true, (0, 1)), (true, false, (1, 0))] { + let (mut s, sub) = subclip_session(video, audio); + s.execute("source.open", json!({"item":sub.0})).unwrap(); + s.execute(command, json!({})).unwrap(); + assert_subclip_placement(&s, sub, expected); + } + } +} + +#[test] +fn subclip_placement_can_add_tracks_for_parent_streams() { + for (video, audio, expected) in [(true, true, (1, 1)), (false, true, (0, 1)), (true, false, (1, 0))] { + let (mut s, sub) = subclip_session(video, audio); + let before = s.project.to_json(); + s.execute("timeline.place", json!({"item":sub.0,"track":"new","audioTrack":"new","time":0})).unwrap(); + assert_eq!(counts(&s), expected); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.project.to_json(), before); + } +} + +#[test] +fn subclip_placement_keeps_parent_channel_map_and_all_audio_streams() { + let (mut s, sub) = subclip_session(true, true); + let parent = s.project.resolve_media(sub).unwrap().0; + let m = std::sync::Arc::make_mut(&mut s.project).item_mut(parent).unwrap().as_media_mut().unwrap(); + m.info.audio_streams.push(m.info.audio_streams[0].clone()); + m.interpret.audio_channels = Some(filmcraft_project::AudioChannelMap { format: filmcraft_project::AudioChannels::Mono, clips: vec![vec![1], vec![0]] }); + s.execute("timeline.place", json!({"item":sub.0,"track":"V1","audioTrack":"A1","time":0})).unwrap(); + assert_eq!(counts(&s), (1, 3)); + let q = s.active_sequence().unwrap(); + let link = q.video_tracks[0].items[0].link; + assert!(link.is_some()); + for (index, (stream, channels)) in [(0, vec![1]), (0, vec![0]), (1, vec![])].into_iter().enumerate() { + let clip = &q.audio_tracks[index].items[0]; + assert_eq!(clip.item, sub); + assert_eq!(clip.audio_stream, stream); + assert_eq!(clip.source_channels, channels); + assert_eq!(clip.link, link); + } +} + #[test] fn malformed_source_spans_are_bounded() { let s = setup(); diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 1291ca053..fb55786db 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -1595,9 +1595,10 @@ impl Project { // a multi-camera source sequence edits in as a multi-camera clip showing its first angle let multicam = it.as_sequence().and_then(|q| q.multicam.as_ref()).map(|m| MulticamSel { enabled: true, angle: m.first_video_angle().unwrap_or(0) as u32 }); - let source_channels = match (kind, &it.kind) { - (TrackKind::Audio, ItemKind::Media(m)) => m.interpret.audio_channels.as_ref().and_then(|a| a.clips.first().cloned()).unwrap_or_default(), - _ => Vec::new(), + let source_channels = if kind == TrackKind::Audio { + self.resolve_media(item).and_then(|(_, m, _)| m.interpret.audio_channels.as_ref()).and_then(|a| a.clips.first().cloned()).unwrap_or_default() + } else { + Vec::new() }; let id = ClipId(self.alloc_id()); let dur = seq_rate.snap_nearest(source.duration).max(seq_rate.frame_duration()); @@ -1671,6 +1672,16 @@ fn resolve_auto_points_sized(e: &mut EffectInstance, frame: (u32, u32), source: } impl Project { + /// Available picture and sound, resolving a subclip to its parent media. Missing parents, + /// cycles and overly deep subclip chains carry neither stream. + pub fn source_streams(&self, item: ItemId) -> (bool, bool) { + let Some(pi) = self.item(item) else { return (false, false) }; + match &pi.kind { + ItemKind::Subclip { .. } => self.resolve_media(item).map_or((false, false), |(_, m, _)| (m.info.has_video(), m.info.has_audio())), + _ => (pi.has_video(), pi.has_audio()), + } + } + /// Resolve media through at most sixteen items, preserving the outermost subclip range. /// Missing parents, cycles and longer chains are unavailable media. pub fn resolve_media(&self, item: ItemId) -> Option<(ItemId, &MediaClip, Option)> { @@ -1895,6 +1906,7 @@ mod tests { fn media_resolution_bounds_depth_and_preserves_the_outer_range() { let (mut p, media, _) = demo_project(); assert_eq!(p.resolve_media(media).map(|(id, _, range)| (id, range)), Some((media, None))); + let streams = p.source_streams(media); let mut parent = media; let mut last_range = TimeRange::default(); for n in 0..15 { @@ -1902,9 +1914,14 @@ mod tests { parent = p.add_item("Sub", Label::Iris, ItemKind::Subclip { parent, range: last_range, restrict_trims: false }, None); } assert_eq!(p.resolve_media(parent).map(|(id, _, range)| (id, range)), Some((media, Some(last_range)))); + assert_eq!(p.source_streams(parent), streams); let too_deep = p.add_item("Too deep", Label::Iris, ItemKind::Subclip { parent, range: last_range, restrict_trims: false }, None); assert!(p.resolve_media(too_deep).is_none()); assert!(p.resolve_media(ItemId(u64::MAX)).is_none()); + assert_eq!(p.source_streams(too_deep), (false, false)); + assert_eq!(p.source_streams(ItemId(u64::MAX)), (false, false)); + p.item_mut(parent).unwrap().kind = ItemKind::Subclip { parent, range: last_range, restrict_trims: false }; + assert_eq!(p.source_streams(parent), (false, false)); } #[test] From 6aa251074375ee2a56f25bb300ee46ef49c7fa8a Mon Sep 17 00:00:00 2001 From: Ikki Nishihara Date: Mon, 12 Oct 2026 02:10:13 +0900 Subject: [PATCH 232/260] =?UTF-8?q?fix:=20=E3=83=8F=E3=82=A4=E3=83=95?= =?UTF-8?q?=E3=83=B3=E5=8C=BA=E5=88=87=E3=82=8A=E6=95=B0=E5=AD=97=E3=81=AE?= =?UTF-8?q?=E5=85=88=E9=A0=AD=E3=82=BC=E3=83=AD=E3=82=92=E4=BF=9D=E6=8C=81?= =?UTF-8?q?=20#910?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/tts-text/src/normalize.rs | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/tts-text/src/normalize.rs b/crates/tts-text/src/normalize.rs index 235d8423a..474e5ab47 100644 --- a/crates/tts-text/src/normalize.rs +++ b/crates/tts-text/src/normalize.rs @@ -674,7 +674,7 @@ fn parse_digit_groups(rest: &str, used: usize) -> Option<(Vec, usize)> { } if groups.len() == 1 { let group = groups.first()?; - let leading_zero = group.starts_with('0'); + let leading_zero = has_leading_zero_str(rest.get(..used)?) || group.starts_with('0'); // Total digits across the whole cluster: the integer before the dash plus this group. let total = rest.get(..used).map_or(0, |head| head.chars().filter(char::is_ascii_digit).count()) + group.len(); if !leading_zero && total < 7 { @@ -701,7 +701,7 @@ fn parse_digit_groups(rest: &str, used: usize) -> Option<(Vec, usize)> { return Some((words, end)); } let total: usize = groups.iter().map(String::len).sum::() + rest.get(..used).map_or(0, |head| head.chars().filter(char::is_ascii_digit).count()); - let leading_zero = groups.iter().any(|g| g.starts_with('0')); + let leading_zero = has_leading_zero_str(rest.get(..used)?) || groups.iter().any(|g| g.starts_with('0')); if total >= 7 || leading_zero { let mut words: Vec = Vec::new(); if let Some(head) = rest.get(..used) { @@ -1204,6 +1204,29 @@ mod tests { assert_eq!(words("$0.50", Lang::EnUs), "zero dollars and fifty cents"); } + #[test] + fn first_digit_group_keeps_leading_zeros() { + for lang in [Lang::EnUs, Lang::EnGb] { + for (input, expected) in [ + ("01-2", "zero one two"), + ("01-2-3", "zero one two three"), + ("0800-55", "zero eight zero zero five five"), + ("0800-1-2", "zero eight zero zero one two"), + ("0-2", "zero to two"), + ("0-2-3", "zero to two to three"), + ("2-3", "two to three"), + ("5-01", "five zero one"), + ("555-0134", "five five five zero one three four"), + ("0800-100", "zero eight zero zero one zero zero"), + ("5-10-15", "five to ten to fifteen"), + ("zero one two", "zero one two"), + ("zero eight zero zero five five", "zero eight zero zero five five"), + ] { + assert_eq!(words(input, lang), expected, "{lang:?}: {input}"); + } + } + } + #[test] fn short_hyphenated_groups_keep_every_number() { for lang in [Lang::EnUs, Lang::EnGb] { From 4823952c038d6975e6fc24a6df245350ccbedc01 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:11:09 +0200 Subject: [PATCH 233/260] Engine: list edge, params and reverse in the sequence.applyAudioTransition schema (#1100) --- crates/engine/src/commands.rs | 2 +- crates/engine/src/mixer_tests.rs | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 6609afddf..3b4956c08 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1740,7 +1740,7 @@ fn build() -> Vec { "Apply Audio Transition", ["Sequence"], Some("Cmd+Shift+D"), - r#"{"clip":id?,"effect":str?,"frames":i64?}"#, + r#"{"clip":id?,"effect":"constant_power|Constant Power|…"?,"frames":i64?,"edge":"in"|"out"?,"params":{param:value}?,"reverse":bool?}"#, has_seq, |s, p| apply_transition(s, p, TrackKind::Audio) ), diff --git a/crates/engine/src/mixer_tests.rs b/crates/engine/src/mixer_tests.rs index faf9243ae..4e218d113 100644 --- a/crates/engine/src/mixer_tests.rs +++ b/crates/engine/src/mixer_tests.rs @@ -520,3 +520,11 @@ fn surround_sequence_settings_and_panner_lanes() { assert!(s.execute("file.newSequence", json!({"mix": "7.1"})).is_err()); s.execute("file.newSequence", json!({"trackType": "Standard"})).unwrap(); } + +#[test] +fn audio_transition_schema_lists_edge_params_and_reverse() { + let params = find_command("sequence.applyAudioTransition").unwrap().params; + for key in ["\"edge\"", "\"params\"", "\"reverse\"", "constant_power"] { + assert!(params.contains(key), "schema `{params}` is missing {key}"); + } +} From c05040c1c042c14bdd01d54f7ffcaf9bae33966d Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:10:59 +0200 Subject: [PATCH 234/260] Timeline: a press on the ruler moves the playhead at once (#1090) --- crates/ui-egui/src/panels/timeline.rs | 32 +++++++++++++++--------- crates/ui-egui/tests/settings_ui.rs | 35 +++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index d5766c0bb..69271c7ad 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1942,6 +1942,20 @@ pub(crate) fn wheel_input(ctx: &egui::Context) -> WheelInput { }) } +/// Puts the playhead where the pointer is on the ruler: what a press does before any drag (#1090). +fn scrub_playhead(app: &mut FilmcraftApp, seq: &Sequence, layout: &Layout, p: Pos2, shift: bool) { + app.stop(); + let mut tt = seq.settings.frame_rate.snap_nearest(layout.tick_at(p.x).max(Tick::ZERO)); + if 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); +} + fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &Layout, rect: Rect) { let ctx = ui.ctx().clone(); let area = Rect::from_min_max(pos2(layout.content.min.x, layout.ruler.min.y), layout.content.max); @@ -1960,7 +1974,12 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & && ctx.input(|i| i.pointer.primary_pressed()) && let Some(p) = ctx.input(|i| i.pointer.press_origin()) { - app.tl.pressed = Some(Pressed { pos: p, tick: layout.tick_at(p.x), grab: grab_at(seq, layout, p, tool, mods, roll_ripple) }); + let grab = grab_at(seq, layout, p, tool, mods, roll_ripple); + // the playhead jumps to a press on the ruler at once, not only once the pointer has moved (#1090) + if matches!(grab, Grab::Other(Hit::Ruler)) { + scrub_playhead(app, seq, layout, p, mods.shift); + } + app.tl.pressed = Some(Pressed { pos: p, tick: layout.tick_at(p.x), grab }); } // ---- wheel, as in Premiere Pro on macOS (checked in 26.5.2): @@ -2183,16 +2202,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & }; if let Some(d) = started { if matches!(d, Drag::Scrub) { - app.stop(); - let mut tt = rate.snap_nearest(layout.tick_at(p.x).max(Tick::ZERO)); - 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); + scrub_playhead(app, seq, layout, p, mods.shift); } if resp.drag_started() { app.tl.drag = Some(d); diff --git a/crates/ui-egui/tests/settings_ui.rs b/crates/ui-egui/tests/settings_ui.rs index eca13d504..e82677464 100644 --- a/crates/ui-egui/tests/settings_ui.rs +++ b/crates/ui-egui/tests/settings_ui.rs @@ -426,3 +426,38 @@ fn playhead_drag_snaps_to_cuts() { let ph = d.harness.state().session.playhead(); assert!(ph > cut, "moves off the cut freely: {ph:?} vs {cut:?}"); } + +/// #1090: a press on the ruler moves the playhead there at once, before the pointer moves or the +/// button comes up, as in Premiere Pro. +#[test] +fn playhead_jumps_to_a_press_on_the_ruler() { + let mut d = Driver::demo(); + d.frames(2); + let seq = d.harness.state().session.active_sequence().unwrap().clone(); + let v1 = &seq.video_tracks[0]; + let cut = v1.items[1].start; + let id = v1.items[1].id.0; + let rect = |d: &mut Driver, id: &str| -> [f64; 4] { + let v = d.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]] + }; + let clip = rect(&mut d, &format!("timeline.clip.{id}")); + let ruler = rect(&mut d, "timeline.ruler"); + d.exec("playhead.set", json!({"time": 0})); + d.frames(2); + assert_eq!(d.harness.state().session.playhead().0, 0); + let p = egui::pos2((clip[0] + 40.0) as f32, (ruler[1] + ruler[3] / 2.0) as f32); + let button = |d: &mut Driver, pressed: bool| { + d.harness.input_mut().events.push(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + d.frames(2); + }; + d.harness.input_mut().events.push(egui::Event::PointerMoved(p)); + d.frames(1); + button(&mut d, true); + let ph = d.harness.state().session.playhead(); + assert!(ph > cut, "the playhead is under the pressed button: {ph:?} vs {cut:?}"); + button(&mut d, false); + assert_eq!(d.harness.state().session.playhead(), ph, "the release leaves it there"); +} From 1faf783b325528af6cc8cd08e1cbe01f4be08da8 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:11:11 +0200 Subject: [PATCH 235/260] Timeline: keep continuous thumbnails fixed while the view zooms (#1094) --- crates/ui-egui/src/panels/timeline.rs | 60 ++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index d5766c0bb..d2a666a71 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -58,6 +58,8 @@ pub struct TlState { /// display gain: scanning the whole source per clip per frame cost more than drawing. peak_max: HashMap, zoom_anchor: Option<(f64, f32)>, + /// The zoom (points a second) the video thumbnails are laid out at while the view is zooming (#1094). + thumb_pps: Option, } impl TlState { @@ -287,6 +289,12 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } let k = 1.0 - (-dt * 20.0).exp(); let zooming = (v.pps - v.target_pps).abs() / v.target_pps > 0.001; + // video thumbnails keep the layout of the zoom a gesture or animation started at (#1094) + if zooming || matches!(app.tl.drag, Some(Drag::ZoomBar { mode: 1 | 2, .. })) { + app.tl.thumb_pps = Some(app.tl.thumb_pps.unwrap_or(v.pps)); + } else { + app.tl.thumb_pps = None; + } v.pps += (v.target_pps - v.pps) * k; if let Some((anchor_t, anchor_x)) = app.tl.zoom_anchor.filter(|_| zooming) { v.scroll = (anchor_t - (anchor_x - content.min.x) as f64 / v.pps).max(0.0); @@ -692,6 +700,31 @@ pub fn thumbnail_tiles(mode: ThumbnailMode, x0: f32, x1: f32, tile_w: f32, visib tiles.into_iter().filter(|(x, _)| shown(*x)).collect() } +/// [`thumbnail_tiles`] while the view zooms (#1094): Continuous tiles are laid out at the zoom +/// the gesture started at (`frozen` points a second) and stretched to where their frames are at +/// the live zoom `pps`, so every tile keeps its frame (cached) instead of asking for a new one at +/// each zoom step. Returns each tile's left edge, width and offset into the clip. +#[allow(clippy::too_many_arguments)] +pub fn zoomed_thumbnail_tiles( + mode: ThumbnailMode, + x0: f32, + x1: f32, + tile_w: f32, + visible: (f32, f32), + dur: Tick, + frame: Tick, + pps: f64, + frozen: Option, +) -> Vec<(f32, f32, Tick)> { + let r = match frozen { + Some(f) if mode == ThumbnailMode::Continuous && f.is_finite() && f > 0.0 && pps.is_finite() && pps > 0.0 && f != pps => (pps / f) as f32, + _ => return thumbnail_tiles(mode, x0, x1, tile_w, visible, dur, frame).into_iter().map(|(x, t)| (x, tile_w, t)).collect(), + }; + let laid_out = |x: f32| x0 + (x - x0) / r; + let tiles = thumbnail_tiles(mode, x0, laid_out(x1), tile_w, (laid_out(visible.0), laid_out(visible.1)), dur, frame); + tiles.into_iter().map(|(x, t)| (x0 + (x - x0) * r, tile_w * r, t)).collect() +} + #[allow(clippy::too_many_arguments)] fn draw_clip( app: &mut FilmcraftApp, @@ -734,7 +767,8 @@ fn draw_clip( 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 (pps, frozen) = (app.ui.timeline.pps, app.tl.thumb_pps); + for (x, w, offset) in zoomed_thumbnail_tiles(mode, th.min.x, th.max.x, tw, visible, it.duration, rate.frame_duration(), pps, frozen) { 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 @@ -748,7 +782,7 @@ fn draw_clip( }) .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())); + let r = Rect::from_min_size(pos2(x, th.min.y), vec2(w, th.height())); cp.image(tex, r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } } @@ -2891,6 +2925,28 @@ mod thumbnail_tiles_tests { const S: Tick = Tick(TICKS_PER_SECOND); const FRAME: Tick = Tick(TICKS_PER_SECOND / 25); + #[test] + fn zooming_keeps_the_frames_shown_before_the_zoom() { + // a 10 s clip 1000 pt wide at 20 pt a second, the panel showing x 0..800 + let dur = Tick(10 * S.0); + let before = thumbnail_tiles(ThumbnailMode::Continuous, 0.0, 1000.0, 80.0, (0.0, 800.0), dur, FRAME); + // halfway through zooming in to 40 pt a second: the clip is 2000 pt wide + let zoomed = zoomed_thumbnail_tiles(ThumbnailMode::Continuous, 0.0, 2000.0, 80.0, (0.0, 800.0), dur, FRAME, 40.0, Some(20.0)); + let offsets: Vec = zoomed.iter().map(|t| t.2).collect(); + let kept: Vec = before.iter().take(5).map(|t| t.1).collect(); + assert_eq!(offsets, kept); + // each frame stretched over the clip time it showed + let placed: Vec<(f32, f32)> = zoomed.iter().map(|t| (t.0, t.1)).collect(); + assert_eq!(placed, [(0.0, 160.0), (160.0, 160.0), (320.0, 160.0), (480.0, 160.0), (640.0, 160.0)]); + // once the zoom settles the tiles are laid out at the new zoom + let settled = thumbnail_tiles(ThumbnailMode::Continuous, 0.0, 2000.0, 80.0, (0.0, 800.0), dur, FRAME); + let expect: Vec<(f32, f32, Tick)> = settled.iter().map(|&(x, t)| (x, 80.0, t)).collect(); + assert_eq!(zoomed_thumbnail_tiles(ThumbnailMode::Continuous, 0.0, 2000.0, 80.0, (0.0, 800.0), dur, FRAME, 40.0, None), expect); + // Head and tail frames do not depend on the zoom + let head = zoomed_thumbnail_tiles(ThumbnailMode::Head, 0.0, 2000.0, 80.0, (0.0, 800.0), dur, FRAME, 40.0, Some(20.0)); + assert_eq!(head, [(0.0, 80.0, Tick::ZERO)]); + } + #[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 From 78a4c73a1e1243f41babcf27f6bc7454e87235e9 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:10:53 +0200 Subject: [PATCH 236/260] Text editing on the Program monitor: double-click and Ctrl/Cmd+A select all the text (#1102) --- crates/ui-egui/src/menus.rs | 3 +++ crates/ui-egui/src/panels/graphics.rs | 19 ++++++++++++++++ crates/ui-egui/tests/graphics_handles_ui.rs | 25 +++++++++++++++++++++ docs/graphics.md | 2 +- 4 files changed, 48 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index f48711505..119061fc6 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -175,6 +175,9 @@ pub fn targets_source(app: &FilmcraftApp, params: &Value) -> bool { /// Execute a UI or engine command by id. pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: Value) -> Result { + if id == "edit.selectAll" && crate::panels::graphics::select_all_edited_text(app) { + return Ok(Value::Null); + } if filmcraft_engine::source_monitor::source_command(id) && targets_source(app, ¶ms) && params.get("target").is_none() { let object = params.as_object_mut().ok_or("command parameters must be an object")?; object.insert("target".into(), json!("source")); diff --git a/crates/ui-egui/src/panels/graphics.rs b/crates/ui-egui/src/panels/graphics.rs index 7cf5f47b3..2f4d6b691 100644 --- a/crates/ui-egui/src/panels/graphics.rs +++ b/crates/ui-egui/src/panels/graphics.rs @@ -835,6 +835,10 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra if resp.double_clicked() { if let Some(v) = views.iter().find(|v| v.is_text() && v.hit(p)) { start_edit_at(app, ui, v, p, &mut actions); + // double-click selects all the text so typing replaces it, as in Premiere + if let (Some(tp), Some(e)) = (text_of(&v.spec), app.ui.gfx_edit.as_mut()) { + (e.anchor, e.caret) = (0, tp.text.len()); + } } } else if let Some(v) = views.iter().find(|v| v.hit(p)) { if app.ui.gfx_edit.is_some() { @@ -964,6 +968,21 @@ fn start_edit_at(app: &mut FilmcraftApp, ui: &egui::Ui, v: &LayerView, p: Pos2, actions.push(("graphics.selectLayer".into(), json!({"clip": v.clip.0, "layers": [v.layer]}))); } +/// Edit ▸ Select All while text is edited on the monitor selects all of that text, not the +/// timeline clips: on macOS the native menu takes Cmd+A before the text sees the key (#1102). +/// False when no text is being edited. +pub(crate) fn select_all_edited_text(app: &mut FilmcraftApp) -> bool { + let Some((clip, layer)) = app.ui.gfx_edit.as_ref().map(|e| (e.clip, e.layer)) else { return false }; + let t = app.session.playhead(); + let Some((_, it)) = app.session.active_sequence().and_then(|q| q.find_item(ClipId(clip))) else { return false }; + let Some(ItemKind::Graphic { width, height, .. }) = app.session.project.item(it.item).map(|p| &p.kind) else { return false }; + let Some(effect) = layer_indices(&it.effects).get(layer).and_then(|&i| it.effects.get(i)) else { return false }; + let Some(len) = eval_layer(effect, it.source_time_at(t), (*width, *height)).as_ref().and_then(text_of).map(|tp| tp.text.len()) else { return false }; + let Some(ed) = app.ui.gfx_edit.as_mut() else { return false }; + (ed.anchor, ed.caret) = (0, len); + true +} + /// 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; diff --git a/crates/ui-egui/tests/graphics_handles_ui.rs b/crates/ui-egui/tests/graphics_handles_ui.rs index b29aa89bb..4ae81a68b 100644 --- a/crates/ui-egui/tests/graphics_handles_ui.rs +++ b/crates/ui-egui/tests/graphics_handles_ui.rs @@ -545,3 +545,28 @@ fn a_hidden_layer_has_no_box_and_cannot_be_clicked() { d.frames(4); assert_eq!(ids(&mut d).len(), 14); } + +/// A double-click on a text layer edits it with all its text selected, so typing replaces it; while +/// it is edited, Select All selects the text again, not the timeline clips, as in Premiere (#1102). +#[test] +fn double_click_and_select_all_select_the_text_being_edited() { + let mut d = Driver::new(); + let (clip, pre) = d.point_text(); + let (a, b) = (d.handle(&pre, 0), d.handle(&pre, 2)); + d.ok("ui.click", json!({"x": (a.0 + b.0) / 2.0, "y": (a.1 + b.1) / 2.0, "count": 2})); + d.frames(4); + let edit = |d: &Driver| d.harness.state().ui.gfx_edit.as_ref().map(|e| (e.clip, e.layer, e.anchor, e.caret)); + assert_eq!(edit(&d), Some((clip, 0, 0, "Title".len())), "the double-click selects the whole text"); + + // caret at the end, then Select All as the native macOS menu sends it (it takes Cmd+A first) + if let Some(e) = d.harness.state_mut().ui.gfx_edit.as_mut() { + (e.anchor, e.caret) = (5, 5); + } + let selection = d.harness.state().session.state.selection.clone(); + let (tx, rx) = channel(); + d.harness.state_mut().command_inbox = Some(rx); + tx.send("edit.selectAll".to_string()).unwrap(); + d.frames(2); + assert_eq!(edit(&d), Some((clip, 0, 0, 5)), "Select All selects the text being edited"); + assert_eq!(d.harness.state().session.state.selection, selection, "and leaves the timeline selection alone"); +} diff --git a/docs/graphics.md b/docs/graphics.md index e19dcbbd1..cacf61732 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 (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. | +| 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 with all its text selected, so typing replaces it; while text is edited, Select All (Cmd/Ctrl+A or Edit ▸ Select All) selects all of that text instead of the timeline clips. | | 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 2c7fd7f1b2641a45f0254745a38c6dfdf44fb588 Mon Sep 17 00:00:00 2001 From: japbcoelho Date: Wed, 7 Oct 2026 01:57:26 -0300 Subject: [PATCH 237/260] Export: unknown keys in `settings` are an error, not ignored A key put inside `settings` that ExportSettings doesn't have did nothing and the export ran as if it were absent: `filmcraft-cli export --settings '{"loudnessLufs":-14}'` (a flat parameter in the wrong place) exported at -18.3 LUFS with no warning, and a misspelt nested key was dropped the same way. settings_from_params now compares the patch with the resolved settings and refuses the first key they don't have, matching keys as `merge` does (the camelCase spelling of a snake_case field, #531, counts): `settings: unknown key `loudnessLufs` (see export.presets.get for the settings' keys)`. A null value is accepted anywhere. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/export_tests.rs | 23 +++++++++++++++++++++++ crates/engine/src/export_tools.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/crates/engine/src/export_tests.rs b/crates/engine/src/export_tests.rs index 3b021bb90..060681aeb 100644 --- a/crates/engine/src/export_tests.rs +++ b/crates/engine/src/export_tests.rs @@ -667,3 +667,26 @@ fn default_export_dir_never_needs_a_real_filesystem() { let s = Session { path: Some("/projects/show/edit.fcproj".into()), ..Default::default() }; assert_eq!(crate::export_tools::default_export_dir(&s), std::path::PathBuf::from("/projects/show")); } + +#[test] +fn unknown_settings_keys_are_errors_not_ignored() { + // A flat parameter put inside `settings` used to be dropped without a word: the export ran + // without loudness normalization. A misspelt nested key was dropped the same way. + let s = demo(); + let err = crate::export_tools::settings_from_params(&s, &json!({"format": "h264", "settings": {"loudnessLufs": -14}}), "file.exportMedia").unwrap_err(); + assert!(err.to_string().contains("`loudnessLufs`"), "{err}"); + let err = crate::export_tools::settings_from_params(&s, &json!({"settings": {"effects": {"loudness": {"targetLoudness": -14}}}}), "file.exportMedia") + .unwrap_err(); + assert!(err.to_string().contains("`effects.loudness.targetLoudness`"), "{err}"); + // Both spellings of a snake_case field are known (#531), and null sets nothing. + for key in ["target_lufs", "targetLufs"] { + let (_, ok) = crate::export_tools::settings_from_params( + &s, + &json!({"settings": {"effects": {"loudness": {"enabled": true, key: -14}}, "captionSidecar": null}}), + "file.exportMedia", + ) + .unwrap(); + assert!(ok.effects.loudness.enabled); + assert_eq!(ok.effects.loudness.target_lufs, -14.0, "{key}"); + } +} diff --git a/crates/engine/src/export_tools.rs b/crates/engine/src/export_tools.rs index b714e284f..e85293056 100644 --- a/crates/engine/src/export_tools.rs +++ b/crates/engine/src/export_tools.rs @@ -159,6 +159,26 @@ fn merge(base: &mut Value, patch: &Value) { } } +/// The first key of `patch` (as a dotted path) that the resolved settings `known` don't have, +/// matched as [`merge`] matches them (the camelCase spelling of a snake_case field counts). +/// A null value is accepted anywhere: it sets nothing. +fn unknown_key(known: &Value, patch: &Value, prefix: &str) -> Option { + let (Value::Object(k), Value::Object(p)) = (known, patch) else { return None }; + for (key, v) in p { + let path = if prefix.is_empty() { key.clone() } else { format!("{prefix}.{key}") }; + match k.get(key).or_else(|| k.get(&camel_to_snake(key))) { + None if !v.is_null() => return Some(path), + Some(kv) => { + if let Some(found) = unknown_key(kv, v, &path) { + return Some(found); + } + } + None => {} + } + } + None +} + /// A frame rate from a number of frames per second (29.97 → 30000/1001) or "num/den". pub fn parse_rate(v: &Value) -> Option { if let Some(s) = v.as_str() { @@ -191,6 +211,12 @@ pub fn settings_from_params(s: &Session, p: &Value, cmd: &str) -> Result<(Option let mut v = serde_json::to_value(&settings).map_err(|e| EngineError::Other(e.to_string()))?; merge(&mut v, patch); settings = serde_json::from_value(v).map_err(|e| bad(cmd, format!("settings: {e}")))?; + // serde ignores keys it doesn't know, so a misspelt or misplaced key (a flat parameter + // such as `loudnessLufs` put inside `settings`) would silently do nothing. + let known = serde_json::to_value(&settings).map_err(|e| EngineError::Other(e.to_string()))?; + if let Some(path) = unknown_key(&known, patch, "") { + return Err(bad(cmd, format!("settings: unknown key `{path}` (see export.presets.get for the settings' keys)"))); + } } // flat overrides (the original file.exportMedia parameters and a few common ones) if let Some(f) = str_p(p, "format") { From e370027e7014f4595f80e2b9db7db86bd9ac5c13 Mon Sep 17 00:00:00 2001 From: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:58:27 +0200 Subject: [PATCH 238/260] fix(captions): apply body and div timing to TTML paragraphs The TTML/DFXP reader ignored begin/end/dur on , so a body begin offset was lost and captions landed early. Body and div are now treated as par time containers: their begins offset descendants, their ends clip them, paragraphs without an end last until the container ends, and end + dur take the earlier. seq containers warn instead of being silently mistimed. Fixes #895 Co-Authored-By: Claude Opus 5.5 --- crates/captions/README.md | 2 +- crates/captions/src/ttml.rs | 89 ++++++++++++++++------ crates/captions/tests/ttml_body_timing.rs | 92 +++++++++++++++++++++++ 3 files changed, 158 insertions(+), 25 deletions(-) create mode 100644 crates/captions/tests/ttml_body_timing.rs diff --git a/crates/captions/README.md b/crates/captions/README.md index d65b03b37..909636fdd 100644 --- a/crates/captions/README.md +++ b/crates/captions/README.md @@ -16,7 +16,7 @@ and writes caption files, converts them to and from caption tracks, and draws ca | Scenarist SCC `.scc` | yes | yes | CEA-608 at 29.97 fps, drop-frame or not. Reads pop-on, roll-up and paint-on (channel 1); writes pop-on. | | MacCaption MCC `.mcc` | yes | yes | One SMPTE ST 334-2 caption distribution packet (CDP) per frame in MacCaption's hex text with letter abbreviations. Writes 29.97 fps (`30DF` / `30`) with CEA-608 field 1 (the SCC schedule) **and** CEA-708 service 1 (two-window pop-on emulation). Reads CEA-608 field 1 when present, else CEA-708 service 1; time code rates 30DF, 30, 60DF, 60, 25, 24, 50. | | EBU STL `.stl` | yes | yes | EBU Tech 3264-E: GSI block + 128-byte TTI blocks, `STL25.01` (25 fps) or `STL30.01` (read/written as 29.97 fps, non-drop labels), ISO/IEC 6937 Latin text (accents as diacritic + letter), italics / underline codes as `` / ``, extension blocks for long subtitles, start-of-programme (TCP) subtracted. | -| TTML `.ttml` (IMSC1) | yes | yes | Writes the IMSC1 Text profile (TTML2 namespaces): `

` per cue, `
`, styled ``s for `` / `` / ``, `ttm:agent` speakers, exact frame times (`123f`) at the sequence rate. Reads any TTML (TTML1, TTML2, the 2006 `ttaf1` drafts): clock / frame / offset / tick times, `dur`, nested time containers, timed spans, referenced styles. | +| TTML `.ttml` (IMSC1) | yes | yes | Writes the IMSC1 Text profile (TTML2 namespaces): `

` per cue, `
`, styled ``s for `` / `` / ``, `ttm:agent` speakers, exact frame times (`123f`) at the sequence rate. Reads any TTML (TTML1, TTML2, the 2006 `ttaf1` drafts): clock / frame / offset / tick times, `dur`, nested time containers (`body` / `div` begins offset and ends clip their contents; `seq` containers are read as `par` with a warning), timed spans, referenced styles. | | DFXP `.dfxp` | yes | yes | TTML1 with the DFXP presentation profile; clock times in milliseconds (frame-exact after `snap_to_frames`). | All cue times are exact `Tick`s. Milliseconds convert exactly (1 ms = 254 016 000 ticks); SCC diff --git a/crates/captions/src/ttml.rs b/crates/captions/src/ttml.rs index 6c98a7ff7..c16d6976b 100644 --- a/crates/captions/src/ttml.rs +++ b/crates/captions/src/ttml.rs @@ -10,8 +10,8 @@ //! //! **Reading** is namespace-agnostic (TTML1 2010, the 2006 `ttaf1` drafts and TTML2 all read): //! clock times (`HH:MM:SS`, `.fraction`, `:FF` frames and `.subframes`), offset times (`h`, `m`, -//! `s`, `ms`, `f`, `t` with `ttp:tickRate`), `begin` / `end` / `dur`, nested time containers -//! (`body` / `div` begins add up), timed ``s inside an untimed `

`, `
`, whitespace +//! `s`, `ms`, `f`, `t` with `ttp:tickRate`), `begin` / `end` / `dur`, nested `par` time +//! containers (`body` / `div` begins add up and their ends clip what is inside), timed ``s inside an untimed `

`, `
`, whitespace //! collapsing, styles referenced by id (italic / bold / underline) and `ttm:agent` / `xml:id`. use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick}; @@ -423,44 +423,84 @@ pub fn parse(text: &str) -> Result { let Some(body) = root.children().find(|n| n.is_element() && n.tag_name().name() == "body") else { return Ok(doc); }; - walk(body, Tick::ZERO, &tm, &styles, Fmt::default(), &mut doc); + if root.descendants().any(|n| attr(n, "timeContainer").is_some_and(|v| v.trim() == "seq")) { + doc.warnings.push("timeContainer=\"seq\" is not supported; its children are timed as in a par container".into()); + } + // the body is a time container like a div: its begin offsets, and its end clips, everything inside + let t = times(body, Scope { begin: Tick::ZERO, end: None }, tm, &mut doc); + walk(body, Scope { begin: t.begin.unwrap_or(Tick::ZERO), end: t.end }, &tm, &styles, Fmt::default(), &mut doc); doc.cues.sort_by_key(|c| (c.start, c.end)); Ok(doc) } -/// Begin and end of a timed element relative to `parent` (None when it has no timing). -fn times(n: roxmltree::Node, parent: Tick, tm: &Timing, doc: &mut Document) -> (Option, Option) { - let p = |k: &str, doc: &mut Document| -> Option { +/// The active interval of the enclosing time containers (`par`): child times are offsets from +/// `begin`, and nothing inside outlasts `end` (None when no ancestor sets an end). +#[derive(Clone, Copy)] +struct Scope { + begin: Tick, + end: Option, +} + +/// Whether `n` has timing attributes of its own. +fn has_timing(n: roxmltree::Node) -> bool { + ["begin", "end", "dur"].into_iter().any(|k| attr(n, k).is_some()) +} + +/// Timing of an element in its parent container. +struct Times { + /// The begin, when the element sets one. + begin: Option, + /// Its own end (`end`, or `begin` + `dur`, the earlier of the two) clipped to the parent's + /// end, else the parent's end. + end: Option, + /// A timing attribute did not parse (and was warned about). + bad: bool, +} + +fn times(n: roxmltree::Node, parent: Scope, tm: Timing, doc: &mut Document) -> Times { + let mut bad = false; + let mut p = |k: &str, doc: &mut Document| -> Option { let v = attr(n, k)?; - let t = parse_time(v, *tm); + let t = parse_time(v, tm); if t.is_none() { + bad = true; doc.warnings.push(format!("bad time expression \"{v}\"")); } t }; - let begin = p("begin", doc).map(|b| parent + b); - let end = match (p("end", doc), p("dur", doc)) { - (Some(e), _) => Some(parent + e), - (None, Some(d)) => Some(begin.unwrap_or(parent) + d), - _ => None, + let begin = p("begin", doc).map(|b| parent.begin + b); + let own_end = match (p("end", doc).map(|e| parent.begin + e), p("dur", doc).map(|d| begin.unwrap_or(parent.begin) + d)) { + (Some(e), Some(d)) => Some(e.min(d)), + (e, d) => e.or(d), }; - (begin, end) + let end = match (own_end, parent.end) { + (Some(e), Some(pe)) => Some(e.min(pe)), + (e, pe) => e.or(pe), + }; + Times { begin, end, bad } } -fn walk(n: roxmltree::Node, parent: Tick, tm: &Timing, styles: &std::collections::HashMap, fmt: Fmt, doc: &mut Document) { +fn walk(n: roxmltree::Node, parent: Scope, tm: &Timing, styles: &std::collections::HashMap, fmt: Fmt, doc: &mut Document) { for c in n.children().filter(|c| c.is_element()) { let f = style_of(c, styles, fmt); match c.tag_name().name() { "div" => { - let (b, _) = times(c, parent, tm, doc); - walk(c, b.unwrap_or(parent), tm, styles, f, doc); + let t = times(c, parent, *tm, doc); + walk(c, Scope { begin: t.begin.unwrap_or(parent.begin), end: t.end }, tm, styles, f, doc); } "p" => { - let (b, e) = times(c, parent, tm, doc); + let t = times(c, parent, *tm, doc); + if t.bad { + continue; + } + let (b, e) = (t.begin.unwrap_or(parent.begin), t.end); let speaker = attr(c, "agent").map(|a| a.trim().trim_start_matches('_').replace('_', " ")); let id = c.attributes().find(|a| a.name() == "id").map(|a| a.value().to_string()); - match (b, e) { - (Some(b), Some(e)) => { + let spans: Vec = c.children().filter(|x| x.is_element() && x.tag_name().name() == "span" && has_timing(*x)).collect(); + // a timed paragraph, or one without timed spans in a container with an end, is one cue + let whole = if has_timing(c) || spans.is_empty() { e } else { None }; + match whole { + Some(e) => { let mut o = Out { s: String::new(), pending: false }; let tags: Vec<&str> = [(f.italic, "i"), (f.bold, "b"), (f.underline, "u")].into_iter().filter(|x| x.0).map(|x| x.1).collect(); for t in &tags { @@ -475,12 +515,13 @@ fn walk(n: roxmltree::Node, parent: Tick, tm: &Timing, styles: &std::collections doc.cues.push(Cue { start: b, end: e, text, speaker, id, ..Default::default() }); } } - _ => { + None => { // untimed paragraph: its timed spans are the cues - let base = b.unwrap_or(parent); - for sp in c.children().filter(|x| x.is_element() && x.tag_name().name() == "span") { - let (sb, se) = times(sp, base, tm, doc); - if let (Some(sb), Some(se)) = (sb, se) { + let scope = Scope { begin: b, end: e }; + for sp in spans { + let st = times(sp, scope, *tm, doc); + let sb = st.begin.unwrap_or(scope.begin); + if let (false, Some(se)) = (st.bad, st.end) { let mut o = Out { s: String::new(), pending: false }; collect(sp, styles, style_of(sp, styles, f), &mut o); let text = clean(&o.s); diff --git a/crates/captions/tests/ttml_body_timing.rs b/crates/captions/tests/ttml_body_timing.rs new file mode 100644 index 000000000..27da2d9f2 --- /dev/null +++ b/crates/captions/tests/ttml_body_timing.rs @@ -0,0 +1,92 @@ +//! TTML / DFXP inherited timing: `begin`, `end` and `dur` on `body` and `div` time containers +//! offset and clip the paragraphs inside them (#895). + +use filmcraft_captions::{Format, WriteOptions, parse, write}; + +/// (start, end, text) of every cue, in seconds; panics on a parse error. +fn cues(xml: &str, format: Format) -> Vec<(f64, f64, String)> { + let doc = parse(xml.as_bytes(), format).unwrap(); + assert!(doc.warnings.is_empty(), "{:?}", doc.warnings); + doc.cues.iter().map(|c| (c.start.seconds(), c.end.seconds(), c.text.clone())).collect() +} + +fn both(xml: &str, want: &[(f64, f64, &str)]) { + let want: Vec<(f64, f64, String)> = want.iter().map(|(s, e, t)| (*s, *e, t.to_string())).collect(); + for format in [Format::Ttml, Format::Dfxp] { + assert_eq!(cues(xml, format), want, "{format:?}: {xml}"); + } +} + +#[test] +fn body_begin_is_inherited() { + both(r#"

x

"#, &[(6.0, 7.0, "x")]); +} + +#[test] +fn div_begin_is_inherited() { + both(r#"

x

"#, &[(6.0, 7.0, "x")]); +} + +#[test] +fn untimed_containers_leave_times_unchanged() { + both(r#"

x

"#, &[(1.0, 2.0, "x")]); +} + +#[test] +fn nested_begins_add_up() { + both( + r#"

deep

mid

top

"#, + &[(6.0, 7.0, "top"), (15.0, 16.0, "mid"), (17.0, 18.0, "deep")], + ); +} + +#[test] +fn body_begin_offsets_timed_spans() { + both(r#"

x

"#, &[(6.0, 7.0, "x")]); +} + +#[test] +fn container_ends_clip_descendants() { + // div active 11 s to 13 s: a later paragraph is dropped, a longer one is cut at 13 s + both( + r#"

cut

gone

"#, + &[(12.0, 13.0, "cut")], + ); + both(r#"

x

"#, &[(1.0, 3.0, "x")]); +} + +#[test] +fn paragraphs_without_an_end_last_until_their_container_ends() { + both( + r#"

open

whole

"#, + &[(3.0, 6.0, "open"), (10.0, 12.0, "whole")], + ); +} + +#[test] +fn end_and_dur_take_the_earlier() { + both(r#"

x

"#, &[(1.0, 3.0, "x")]); +} + +#[test] +fn seq_containers_warn() { + let xml = r#"

x

"#; + for format in [Format::Ttml, Format::Dfxp] { + let doc = parse(xml.as_bytes(), format).unwrap(); + assert_eq!(doc.cues.len(), 1); + assert!(doc.warnings.iter().any(|w| w.contains("seq")), "{:?}", doc.warnings); + } +} + +#[test] +fn inherited_times_survive_export() { + // the writer uses untimed body / div and absolute paragraph times, so the resolved times stay + let xml = r#"

x

"#; + for format in [Format::Ttml, Format::Dfxp] { + let doc = parse(xml.as_bytes(), format).unwrap(); + let back = parse(&write(&doc, format, WriteOptions::default()), format).unwrap(); + let times = |d: &filmcraft_captions::Document| d.cues.iter().map(|c| (c.start, c.end, c.text.clone())).collect::>(); + assert_eq!(times(&back), times(&doc), "{format:?}"); + assert_eq!((back.cues[0].start.seconds(), back.cues[0].end.seconds()), (7.0, 9.0), "{format:?}"); + } +} From e39406182703180c93f3575ae8efecb060fa8af7 Mon Sep 17 00:00:00 2001 From: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:02:15 +0200 Subject: [PATCH 239/260] fix(cli): fail export/render that drew offline media unless --allow-offline Exports and rendered frames now record which offline items (missing, made offline, unreadable) they drew the placeholder slate for. The engine reports them as `offlineMedia` in file.exportMedia (wait), jobs.list export entries and export.queue items; the bridge and MCP long exports pass it through. filmcraft-cli export and render name each item on stderr and exit 1, unless --allow-offline accepts the placeholders. Fixes #1054 Co-Authored-By: Claude Opus 5.5 --- apps/filmcraft-cli/src/main.rs | 78 +++++++++++++++++++++++++++---- apps/filmcraft-cli/tests/cli.rs | 52 +++++++++++++++++++++ crates/automation/src/bridge.rs | 30 ++++++++++-- crates/automation/src/long_job.rs | 4 ++ crates/engine/src/commands.rs | 2 +- crates/engine/src/export_tools.rs | 22 ++++++++- crates/engine/src/lib.rs | 19 +++++++- crates/engine/src/media_pool.rs | 52 +++++++++++++++++++-- crates/engine/src/relink_tests.rs | 69 +++++++++++++++++++++++++++ docs/agents.md | 12 +++++ 10 files changed, 319 insertions(+), 21 deletions(-) diff --git a/apps/filmcraft-cli/src/main.rs b/apps/filmcraft-cli/src/main.rs index 10592444f..55d4b8467 100644 --- a/apps/filmcraft-cli/src/main.rs +++ b/apps/filmcraft-cli/src/main.rs @@ -76,7 +76,7 @@ SUBCOMMANDS import ... import media into the project export [--preset name] [--format f] [--range r] [--start s --end s] [--settings json] [--gpu-rendering auto|off] - [--scale f] [--quality 0-100] [--no-audio] [--queue] + [--scale f] [--quality 0-100] [--no-audio] [--queue] [--allow-offline] 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|aac, guessed @@ -85,9 +85,12 @@ SUBCOMMANDS preset; --scale renders at a fraction of the frame size (0.5 = half); --quality 0-100 for the formats that take one; --no-audio leaves the sound out; --queue adds to the export queue and runs it - instead + instead. Offline media (missing, made offline, unreadable) renders + as placeholder frames: the export then lists it in `offlineMedia`, + warns on stderr and exits 1, unless --allow-offline accepts it export --list-presets [query] list export presets (name, category, format) as JSON - render --seconds S --out f.png [--scale 0.5] render one Program frame to PNG + render --seconds S --out f.png [--scale 0.5] [--allow-offline] + render one Program frame to PNG (offline media: as for export) probe [--image-sequence] media info as JSON, with MXF / Ogg / BWF details; with --image-sequence is the first numbered still of a sequence @@ -113,7 +116,8 @@ VALUES Times: 254016000000 ticks per second; most commands also take seconds=, frame= or timecode=. EXIT STATUS - 0 success · 1 a command failed · 2 usage error + 0 success · 1 a command failed, or export/render drew offline media without --allow-offline + · 2 usage error "; /// Writes one diagnostic line to `w`, dropping any write error. `eprintln!` panics (exit 101) when stderr's @@ -442,7 +446,9 @@ async fn cli() { match r { Ok(v) => { print(&a, &v); - diag(format_args!("exported {} in {:.1}s", written_paths(&v, out).join(", "), t0.elapsed().as_secs_f64())); + let written = written_paths(&v, out).join(", "); + check_offline(&a, &offline_media(&v), &format!("export: {written}")); + diag(format_args!("exported {written} in {:.1}s", t0.elapsed().as_secs_f64())); } Err(e) => fail(format!("export: {e}")), } @@ -457,10 +463,12 @@ async fn cli() { let out = a.opt("--out").unwrap_or("frame.png"); s.set_playhead(filmcraft_time::Tick::from_seconds_f64(secs)); let t0 = std::time::Instant::now(); - let img = render_at_playhead(&s, scale).unwrap_or_else(|e| fail(e)); + let (img, offline) = render_at_playhead(&s, scale).unwrap_or_else(|e| fail(e)); let dt = t0.elapsed(); let png = filmcraft_automation::png_rgba(img.w as u32, img.h as u32, img.over_black_rgba8(), 0).unwrap_or_else(|e| fail(e)); std::fs::write(out, png).unwrap_or_else(|e| fail(format!("{out}: {e}"))); + let offline: Vec = offline.iter().filter_map(|o| serde_json::to_value(o).ok()).collect(); + check_offline(&a, &offline, &format!("render: {out}")); diag(format_args!("rendered {}x{} in {:.1} ms → {out}", img.w, img.h, dt.as_secs_f64() * 1000.0)); } "mcp" => { @@ -500,9 +508,46 @@ fn written_paths(result: &Value, requested: &str) -> Vec { if paths.is_empty() { vec![requested.to_string()] } else { paths } } -/// The active sequence at the playhead, or the reason it cannot be rendered (no sequence, bad scale). -fn render_at_playhead(s: &Session, scale: f32) -> Result { - s.try_render_program_at(scale, s.playhead()).map_err(|e| e.to_string()) +/// The offline items an export result reports (`offlineMedia` of a direct export, or of each +/// queued item), once each. +fn offline_media(result: &Value) -> Vec { + let lists = std::iter::once(result).chain(result.get("items").and_then(Value::as_array).into_iter().flatten()); + let mut out: Vec = Vec::new(); + for o in lists.filter_map(|r| r.get("offlineMedia").and_then(Value::as_array)).flatten() { + if !out.iter().any(|x| x.get("item") == o.get("item")) { + out.push(o.clone()); + } + } + out +} + +/// One stderr line per offline item that rendered as placeholder frames (#1054). +fn offline_warning(offline: &[Value]) -> String { + let s = |o: &Value, k: &str| o.get(k).and_then(Value::as_str).unwrap_or("").to_string(); + let mut msg = format!("filmcraft-cli: warning: {} offline media item(s) rendered as placeholder frames:", offline.len()); + for o in offline { + let id = o.get("item").and_then(Value::as_u64).map(|i| i.to_string()).unwrap_or_default(); + msg.push_str(&format!("\n item {id} {} ({}): {}", s(o, "name"), s(o, "status"), s(o, "path"))); + } + msg +} + +/// Warn about offline media on stderr; without `--allow-offline` that fails the run (exit 1), after +/// the output (`what`) was written, so scripts don't take placeholder frames for the real picture. +fn check_offline(a: &Args, offline: &[Value], what: &str) { + if offline.is_empty() { + return; + } + diag(format_args!("{}", offline_warning(offline))); + if !a.flag("--allow-offline") { + fail(format!("{what} has placeholder frames for offline media; relink it (exec media.relink …) or pass --allow-offline")); + } +} + +/// The active sequence at the playhead with the offline items it drew, or the reason it cannot be +/// rendered (no sequence, bad scale). +fn render_at_playhead(s: &Session, scale: f32) -> Result<(filmcraft_engine::render::Image, Vec), String> { + s.try_render_program_with_offline_at(scale, s.playhead()).map_err(|e| e.to_string()) } /// `--start` / `--end` ask for a custom range. A lone bound is forwarded too, so the engine reports the missing one @@ -531,7 +576,7 @@ mod format_tests { let e = super::render_at_playhead(&s, scale).unwrap_err(); assert!(e.contains("finite and positive"), "{e}"); } - assert_eq!(super::render_at_playhead(&s, 0.5).unwrap().w, 8); + assert_eq!(super::render_at_playhead(&s, 0.5).unwrap().0.w, 8); } /// The CLI (and MCP / headless runs, which share its entry point) registers the hardware @@ -573,6 +618,19 @@ mod format_tests { assert_eq!(super::written_paths(&json!({"items": []}), "x.mov"), ["x.mov"]); } + #[test] + fn offline_media_from_direct_and_queued_results() { + use serde_json::json; + let a = json!({"item": 8, "name": "a.mp4", "path": "/m/a.mp4", "status": "missing", "error": ""}); + let b = json!({"item": 9, "name": "b.mov", "path": "/m/b.mov", "status": "offline", "error": ""}); + assert_eq!(super::offline_media(&json!({"path": "x.mp4", "offlineMedia": [a]})), std::slice::from_ref(&a)); + let queued = json!({"items": [{"offlineMedia": [a, b]}, {"offlineMedia": [a]}, {"offlineMedia": []}]}); + assert_eq!(super::offline_media(&queued), [a.clone(), b.clone()]); + assert!(super::offline_media(&json!({"path": "x.mp4"})).is_empty()); + let w = super::offline_warning(&[a, b]); + assert!(w.contains("2 offline media item(s)") && w.contains("item 8 a.mp4 (missing): /m/a.mp4") && w.contains("item 9 b.mov (offline)"), "{w}"); + } + #[test] fn export_lone_bound_is_forwarded_as_custom_range() { use serde_json::json; diff --git a/apps/filmcraft-cli/tests/cli.rs b/apps/filmcraft-cli/tests/cli.rs index 8552cc640..6cafe0e91 100644 --- a/apps/filmcraft-cli/tests/cli.rs +++ b/apps/filmcraft-cli/tests/cli.rs @@ -291,3 +291,55 @@ fn closed_stdout_still_runs_the_script_and_saves() { assert_eq!(out.status.code(), Some(1)); let _ = std::fs::remove_dir_all(&dir); } + +/// #1054: exporting or rendering a project whose media is missing fails (exit 1) and names the +/// offline items on stderr and in the result's `offlineMedia`, unless `--allow-offline` accepts the +/// placeholder frames; then it still warns. +#[test] +fn export_with_missing_media_fails_unless_allowed() { + let dir = std::env::temp_dir().join(format!("fc-cli-1054-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let f = |n: &str| dir.join(n).to_str().unwrap().to_string(); + let (still, proj) = (f("still.png"), f("p.fcproj")); + let o = cli(&["--demo", "render", "--seconds", "1", "--scale", "0.1", "--out", &still]); + assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr)); + json_out(&cli(&["exec", "file.newSequence", "name=s", "width=64", "height=36", "fps=24", "--save-as", &proj])); + let paths = format!("paths=[\"{}\"]", still.replace('\\', "\\\\")); + let item = json_out(&cli(&["--project", &proj, "--save", "exec", "file.import", &paths]))["items"][0].as_u64().unwrap(); + json_out(&cli(&["--project", &proj, "--save", "exec", "timeline.place", &format!("item={item}"), "track=V1", "frame=0", "audio=false"])); + let export = |out: &str, extra: &[&str]| { + let mut args = vec!["--project", proj.as_str(), "--compact", "export", out, "--format", "png", "--start", "0", "--end", "0.05"]; + args.extend_from_slice(extra); + cli(&args) + }; + + let on = export(&f("on.png"), &[]); + let v = json_out(&on); + assert_eq!(v["offlineMedia"], serde_json::json!([]), "{v}"); + assert!(!String::from_utf8_lossy(&on.stderr).contains("warning"), "{}", String::from_utf8_lossy(&on.stderr)); + + std::fs::remove_file(&still).unwrap(); + let off = export(&f("off.png"), &[]); + let err = String::from_utf8_lossy(&off.stderr); + assert_eq!(off.status.code(), Some(1), "{err}"); + assert!(err.contains("still.png") && err.contains("missing") && err.contains("--allow-offline"), "{err}"); + let v: Value = serde_json::from_slice(&off.stdout).expect("the result JSON is still printed"); + assert_eq!(v["offlineMedia"][0]["item"], item, "{v}"); + assert_eq!(v["offlineMedia"][0]["status"], "missing", "{v}"); + + let allowed = export(&f("allowed.png"), &["--allow-offline"]); + let err = String::from_utf8_lossy(&allowed.stderr); + assert!(allowed.status.success(), "{err}"); + assert!(err.contains("still.png") && err.contains("missing"), "still warns: {err}"); + assert_eq!(json_out(&allowed)["offlineMedia"][0]["status"], "missing"); + + let render = cli(&["--project", &proj, "render", "--seconds", "0", "--out", &f("r.png")]); + let err = String::from_utf8_lossy(&render.stderr); + assert_eq!(render.status.code(), Some(1), "{err}"); + assert!(err.contains("still.png") && err.contains("--allow-offline"), "{err}"); + let render = cli(&["--project", &proj, "render", "--seconds", "0", "--out", &f("r.png"), "--allow-offline"]); + assert!(render.status.success(), "{}", String::from_utf8_lossy(&render.stderr)); + assert!(String::from_utf8_lossy(&render.stderr).contains("still.png")); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/automation/src/bridge.rs b/crates/automation/src/bridge.rs index b7d3e197a..5fdc2f0df 100644 --- a/crates/automation/src/bridge.rs +++ b/crates/automation/src/bridge.rs @@ -47,14 +47,14 @@ impl BridgeClient { } let mut start = self.call("engine.execute", json!({"command": id, "params": params})).await?; let Some(job) = start.get("job").and_then(Value::as_u64) else { return Ok(start) }; - let result = loop { + let (result, offline) = loop { tokio::time::sleep(JOB_POLL).await; let jobs = self.call("engine.execute", json!({"command": "jobs.list", "params": {}})).await?; let Some(j) = jobs.as_array().and_then(|a| a.iter().find(|j| j["id"].as_u64() == Some(job))).cloned() else { - break Value::Null; + break (Value::Null, None); }; if j["finished"].as_bool() == Some(true) { - break j["result"].clone(); + break (j["result"].clone(), j.get("offlineMedia").cloned()); } }; if let Some(e) = result.get("error").and_then(Value::as_str) { @@ -62,6 +62,9 @@ impl BridgeClient { } if let Some(o) = start.as_object_mut() { o.insert("result".into(), result); + if let Some(off) = offline { + o.insert("offlineMedia".into(), off); + } } Ok(start) } @@ -121,6 +124,11 @@ mod tests { /// A stand-in for the app's control server: the export starts job 7, which `jobs.list` reports /// running twice and then finished with `result`. Every request is recorded. async fn fake_app(result: Value) -> (String, Arc>>) { + fake_app_with(result, None).await + } + + /// [`fake_app`] whose finished job also lists `offline` as its `offlineMedia`. + async fn fake_app_with(result: Value, offline: Option) -> (String, Arc>>) { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap().to_string(); let seen = Arc::new(Mutex::new(Vec::new())); @@ -138,7 +146,11 @@ mod tests { Some("jobs.list") => { polls += 1; let finished = polls >= 3; - json!([{"id": 7, "finished": finished, "result": if finished { result.clone() } else { Value::Null }}]) + let mut j = json!({"id": 7, "finished": finished, "result": if finished { result.clone() } else { Value::Null }}); + if let Some(o) = offline.clone().filter(|_| finished) { + j["offlineMedia"] = o; + } + json!([j]) } _ => json!(null), }; @@ -165,6 +177,16 @@ mod tests { assert_eq!(seen.iter().filter(|q| q["params"]["command"] == "jobs.list").count(), 3); } + /// The offline media a polled export drew comes back as in a blocking export's reply (#1054). + #[tokio::test] + async fn a_polled_export_reports_its_offline_media() { + let off = json!([{"item": 8, "status": "missing"}]); + let (addr, _) = fake_app_with(json!({"frames": 3}), Some(off.clone())).await; + let b = BridgeClient::new(addr).unwrap(); + let r = b.execute("file.exportMedia", json!({"path": "/tmp/out.mov", "wait": true})).await.unwrap(); + assert_eq!(r["offlineMedia"], off, "{r}"); + } + #[tokio::test] async fn a_failed_blocking_export_is_an_error() { let (addr, _) = fake_app(json!({"error": "encode: disk full"})).await; diff --git a/crates/automation/src/long_job.rs b/crates/automation/src/long_job.rs index 62549b903..bdc756cf5 100644 --- a/crates/automation/src/long_job.rs +++ b/crates/automation/src/long_job.rs @@ -72,6 +72,7 @@ impl FilmcraftMcp { rep.report(done, total, j["status"].as_str().unwrap_or_default()).await; } if finished { + let offline = state.as_ref().and_then(|j| j.get("offlineMedia").cloned()); let result = state.map(|j| j["result"].clone()).unwrap_or(Value::Null); if let Some(e) = result.get("error").and_then(Value::as_str) { return CallToolResult::error(vec![Content::text(format!("export failed: {e}"))]); @@ -79,6 +80,9 @@ impl FilmcraftMcp { let mut out = start; if let Some(o) = out.as_object_mut() { o.insert("result".into(), result); + if let Some(off) = offline { + o.insert("offlineMedia".into(), off); + } } return CallToolResult::success(vec![Content::text(serde_json::to_string_pretty(&out).unwrap_or_default())]); } diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 6609afddf..7c58a1ed0 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1289,7 +1289,7 @@ fn build() -> Vec { has_seq, crate::export_tools::export_media ), - query!("jobs.list", "List Jobs", "{}", |s, _| Ok(Value::Array(s.jobs.iter().map(crate::Job::to_json).collect()))), + query!("jobs.list", "List Jobs", "{}", |s, _| Ok(Value::Array(s.jobs.iter().map(|j| crate::export_tools::job_json(s, j)).collect()))), query!("perf.stats", "Performance Statistics", "{}", |s, _| Ok(crate::perf::stats(s))), cmd!("jobs.cancel", "Cancel Job", [], None, r#"{"job":id}"#, always, |s, p| { let id = u64_p(p, "job").ok_or_else(|| bad("jobs.cancel", "need `job`"))?; diff --git a/crates/engine/src/export_tools.rs b/crates/engine/src/export_tools.rs index b714e284f..248407540 100644 --- a/crates/engine/src/export_tools.rs +++ b/crates/engine/src/export_tools.rs @@ -440,8 +440,9 @@ pub fn spawn_export(s: &mut Session, project: Arc, seq: ItemId, mut set let id = s.jobs.iter().map(|j| j.id).max().unwrap_or(0) + 1; let job = crate::Job { id, label, progress: Default::default(), result: Default::default() }; // Export always renders full-resolution media, whatever the proxy toggle says. - let provider = s.media.full_res_provider(project.clone(), s.services.clone()); + let provider = s.media.full_res_provider(project.clone(), s.services.clone()).recording(); let (prog, res) = (job.progress.clone(), job.result.clone()); + let offline = s.export_offline.entry(id).or_default().clone(); if cfg!(target_arch = "wasm32") && !wait && filmcraft_export::stepped(format) { // No threads: the host advances the export between UI frames (`Session::pump_jobs`). let mut exporter = filmcraft_export::Exporter::new(project, seq, &settings, &prog).map_err(|e| EngineError::Other(e.to_string()))?; @@ -452,6 +453,7 @@ pub fn spawn_export(s: &mut Session, project: Arc, seq: ItemId, mut set } let run = move || { let r = filmcraft_export::export(&project, seq, &settings, &provider, &prog).map_err(|e| e.to_string()); + *offline.lock().unwrap_or_else(|x| x.into_inner()) = provider.offline_used(); if let Err(e) = &r { *prog.error.lock().unwrap_or_else(|x| x.into_inner()) = Some(e.clone()); prog.finished.store(true, Ordering::Relaxed); @@ -514,6 +516,7 @@ pub fn export_media(s: &mut Session, p: &Value) -> Result { return Err(EngineError::Other(format!("export failed: {e}"))); } out["result"] = v["result"].clone(); + out["offlineMedia"] = offline_media_json(s, id); } Ok(out) } @@ -568,6 +571,22 @@ impl ExportQueue { } } +/// A `jobs.list` entry: [`crate::Job::to_json`], plus `offlineMedia` for an export job, so callers +/// that poll an export (the bridge, MCP long exports) can report it like `file.exportMedia` does. +pub(crate) fn job_json(s: &Session, j: &crate::Job) -> Value { + let mut v = j.to_json(); + if s.export_offline.contains_key(&j.id) { + v["offlineMedia"] = offline_media_json(s, j.id); + } + v +} + +/// The offline items export job `job` drew the slate for, as JSON (`[]` while it runs or when none). +fn offline_media_json(s: &Session, job: u64) -> Value { + let list = s.export_offline.get(&job).map(|o| o.lock().unwrap_or_else(|e| e.into_inner()).clone()).unwrap_or_default(); + serde_json::to_value(list).unwrap_or_else(|_| json!([])) +} + fn item_json(s: &Session, it: &QueueItem) -> Value { let job = it.job.and_then(|id| s.jobs.iter().find(|j| j.id == id)); let (progress, status_text) = match job { @@ -588,6 +607,7 @@ fn item_json(s: &Session, it: &QueueItem) -> Value { "statusText": status_text, "job": it.job, "error": it.error, + "offlineMedia": it.job.map_or_else(|| json!([]), |j| offline_media_json(s, j)), }) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a2ed9e6a2..b0454caa9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -357,6 +357,9 @@ pub struct Session { pub journal: Vec<(String, Value)>, /// Background jobs (exports, render previews…). pub jobs: Vec, + /// Export job → the offline items it drew the slate for, filled when the export finishes + /// (#1054; `offlineMedia` in `file.exportMedia` and queue results). + pub export_offline: std::collections::HashMap>>>, /// User preferences (`prefs.*` commands) and where they persist (None = not persisted). pub prefs: autosave::Preferences, pub prefs_path: Option, @@ -476,6 +479,7 @@ impl Session { events: Vec::new(), journal: Vec::new(), jobs: Vec::new(), + export_offline: Default::default(), prefs: Default::default(), prefs_path: None, persistence: None, @@ -1024,11 +1028,22 @@ impl Session { /// [`Session::render_program_at`], with the reason when nothing can be rendered. pub fn try_render_program_at(&self, scale: f32, t: Tick) -> Result { + self.render_program_with(scale, t, &self.media.provider(self.project.clone(), self.services.clone())) + } + + /// [`Session::try_render_program_at`], also naming the offline items the frame drew the slate + /// for (#1054). + pub fn try_render_program_with_offline_at(&self, scale: f32, t: Tick) -> Result<(filmcraft_render::Image, Vec)> { + let provider = self.media.provider(self.project.clone(), self.services.clone()).recording(); + let img = self.render_program_with(scale, t, &provider)?; + Ok((img, provider.offline_used())) + } + + fn render_program_with(&self, scale: f32, t: Tick, provider: &dyn filmcraft_render::SourceProvider) -> Result { let seq = self.renderable_sequence(scale)?; let t = self.sequence_rate().snap(t.max(Tick::ZERO)); - let provider = self.media.provider(self.project.clone(), self.services.clone()); let opts = filmcraft_render::RenderOptions { scale, captions: true, ..Default::default() }; - Ok(filmcraft_render::render_sequence(&self.project, seq, t, opts, &provider)) + Ok(filmcraft_render::render_sequence(&self.project, seq, t, opts, provider)) } /// The active sequence, if a frame of it at `scale` is within the image size limits. diff --git a/crates/engine/src/media_pool.rs b/crates/engine/src/media_pool.rs index ac849710c..ee8a1f018 100644 --- a/crates/engine/src/media_pool.rs +++ b/crates/engine/src/media_pool.rs @@ -12,7 +12,7 @@ //! compositor derives its pixel scale from the frame it gets, so effects and Motion render the //! same picture at lower resolution. Export asks for [`MediaPool::full_res_provider`]. -use std::collections::HashMap; +use std::collections::{BTreeSet, HashMap}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, RwLock}; @@ -32,6 +32,17 @@ pub struct OfflineStatus { pub error: String, } +/// An offline item an export or a rendered frame drew the slate for (#1054). `status` is the +/// word `media.status` uses: `missing`, `offline` (made offline) or `unreadable`. +#[derive(Clone, Debug, PartialEq, serde::Serialize)] +pub struct OfflineMedia { + pub item: u64, + pub name: String, + pub path: String, + pub status: &'static str, + pub error: String, +} + pub struct MediaPool { /// item → (reference key, source) sources: RwLock>, @@ -316,12 +327,12 @@ impl MediaPool { /// A render-side provider bound to a project snapshot (proxies when enabled). pub fn provider(self: &Arc, project: Arc, services: Arc) -> PoolProvider { - PoolProvider { pool: self.clone(), project, services, full_res: false } + PoolProvider { pool: self.clone(), project, services, full_res: false, used: None } } /// A provider that always reads full-resolution media (export). pub fn full_res_provider(self: &Arc, project: Arc, services: Arc) -> PoolProvider { - PoolProvider { pool: self.clone(), project, services, full_res: true } + PoolProvider { pool: self.clone(), project, services, full_res: true, used: None } } } @@ -332,10 +343,45 @@ pub struct PoolProvider { pub services: Arc, /// Ignore proxies (export always renders full resolution). pub full_res: bool, + /// Items asked for so far, when recording ([`PoolProvider::recording`]). + used: Option>>, +} + +impl PoolProvider { + /// This provider, remembering the items it is asked for so [`PoolProvider::offline_used`] can + /// tell which of them rendered offline. + pub fn recording(mut self) -> Self { + self.used = Some(Mutex::new(BTreeSet::new())); + self + } + + /// The offline items among those asked for since [`PoolProvider::recording`], once each, in + /// item order (subclips count as their media). Empty when not recording. + pub fn offline_used(&self) -> Vec { + let Some(used) = &self.used else { return Vec::new() }; + let used = used.lock().unwrap_or_else(|e| e.into_inner()).clone(); + let media: BTreeSet = used.into_iter().filter_map(|i| self.project.resolve_media(i).map(|(id, _, _)| id)).collect(); + media + .into_iter() + .filter_map(|id| { + let st = self.pool.offline_status(id)?; + let status = match st.reason { + OfflineReason::Missing => "missing", + OfflineReason::MadeOffline => "offline", + OfflineReason::Unreadable => "unreadable", + }; + let name = self.project.item(id).map(|i| i.name.clone()).unwrap_or_default(); + Some(OfflineMedia { item: id.0, name, path: st.path, status, error: st.error }) + }) + .collect() + } } impl filmcraft_render::SourceProvider for PoolProvider { fn source(&self, item: ItemId) -> Option { + if let Some(used) = &self.used { + used.lock().unwrap_or_else(|e| e.into_inner()).insert(item); + } if self.full_res { self.pool.full_res_source(&self.project, item, &*self.services) } else { self.pool.source_for(&self.project, item, &*self.services) } } } diff --git a/crates/engine/src/relink_tests.rs b/crates/engine/src/relink_tests.rs index b6475c0d4..d25dda91e 100644 --- a/crates/engine/src/relink_tests.rs +++ b/crates/engine/src/relink_tests.rs @@ -201,3 +201,72 @@ fn explicit_folder_remaps_normalize_both_prefixes_and_respect_boundaries() { assert_eq!(apply_remap("C:/shoot/Media/a.mov", r"C:\shoot\Media", r"D:\archive\Media"), Some("D:/archive/Media/a.mov".into())); assert_eq!(apply_remap("C:/shoot/Media2/a.mov", r"C:\shoot\Media", r"D:\archive\Media"), None); } + +/// #1054: an export (or a rendered frame) that draws the offline slate names the offline items it +/// used, with their status, so a headless caller can tell placeholder frames from real ones. Media +/// outside the exported range is not reported. +#[test] +fn export_names_the_offline_media_it_rendered() { + let root = tmp_dir("relink-export-offline"); + let (path, items) = saved_project(&root); + let out = |name: &str| root.join(name).to_string_lossy().into_owned(); + let mut s = open(&path); + let r = s + .execute("file.exportMedia", json!({"path": out("on.png"), "format": "png", "range": "custom", "startSeconds": 0, "endSeconds": 0.05, "wait": true})) + .unwrap(); + assert_eq!(r["offlineMedia"], json!([]), "{r}"); + std::fs::remove_file(root.join("Media/a.mov")).unwrap(); + let mut s = open(&path); + // b.mov (frames 12..24) is still there + let r = s + .execute("file.exportMedia", json!({"path": out("b.png"), "format": "png", "range": "custom", "startSeconds": 0.6, "endSeconds": 0.65, "wait": true})) + .unwrap(); + assert_eq!(r["offlineMedia"], json!([]), "{r}"); + let r = s.execute("file.exportMedia", json!({"path": out("all.wav"), "format": "wav", "wait": true})).unwrap(); + let off = r["offlineMedia"].as_array().unwrap(); + assert_eq!(off.len(), 1, "{r}"); + assert_eq!(off[0]["item"], json!(items[0].0)); + assert_eq!(off[0]["name"], json!("a.mov")); + assert_eq!(off[0]["status"], json!("missing")); + assert!(off[0]["path"].as_str().unwrap().ends_with("a.mov"), "{r}"); + // made offline on purpose + s.execute("media.makeOffline", json!({"items": [items[1].0]})).unwrap(); + let r = s + .execute("file.exportMedia", json!({"path": out("b2.png"), "format": "png", "range": "custom", "startSeconds": 0.6, "endSeconds": 0.65, "wait": true})) + .unwrap(); + assert_eq!( + r["offlineMedia"].as_array().map(|a| a.iter().map(|o| (o["item"].clone(), o["status"].clone())).collect::>()), + Some(vec![(json!(items[1].0), json!("offline"))]), + "{r}" + ); + // the queue reports it per item + let r = s + .execute( + "export.queue.add", + json!({"path": out("q.png"), "format": "png", "range": "custom", "startSeconds": 0.6, "endSeconds": 0.65, "start": true, "wait": true}), + ) + .unwrap(); + assert_eq!(r["items"][0]["offlineMedia"][0]["status"], json!("offline"), "{r}"); + // one rendered frame + let (_, off) = s.try_render_program_with_offline_at(0.5, s.sequence_rate().tick_of(3)).unwrap(); + assert_eq!(off.iter().map(|o| (o.item, o.status)).collect::>(), [(items[0].0, "missing")]); + let (_, off) = s.try_render_program_with_offline_at(0.5, s.sequence_rate().tick_of(30)).unwrap(); + assert!(off.is_empty(), "nothing on the timeline there: {off:?}"); + let _ = std::fs::remove_dir_all(&root); +} + +/// #1054: `jobs.list` carries an export job's `offlineMedia`, for callers that poll the job (the +/// bridge and MCP long exports) instead of waiting in `file.exportMedia`. +#[test] +fn jobs_list_reports_an_exports_offline_media() { + let root = tmp_dir("relink-jobs-offline"); + let (path, items) = saved_project(&root); + std::fs::remove_file(root.join("Media/b.mov")).unwrap(); + let mut s = open(&path); + let out = root.join("all.wav").to_string_lossy().into_owned(); + let job = s.execute("file.exportMedia", json!({"path": out, "format": "wav", "wait": true})).unwrap()["job"].clone(); + let jobs = s.execute("jobs.list", json!({})).unwrap(); + let j = jobs.as_array().unwrap().iter().find(|j| j["id"] == job).unwrap(); + assert_eq!(j["offlineMedia"][0]["item"], json!(items[1].0), "{j}"); + let _ = std::fs::remove_dir_all(&root); +} diff --git a/docs/agents.md b/docs/agents.md index 50b4e34e4..b10ff5f27 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -110,6 +110,9 @@ progress and stop it: - The export runs as the same background job `jobs.list` shows. The session is locked only for short polls, so other requests (`ping`, `doc_inspect`, `jobs.list`, …) are answered while it encodes. +- `file.exportMedia` with `wait: true`, an export job in `jobs.list` and each `export.queue.list` + item carry `offlineMedia`: the offline items the export drew the placeholder for (see §2b), `[]` + when none. The export itself still succeeds; deciding what to do about it is up to the caller. - Every job in `jobs.list`, and every encoding item of `export.queue.list`, carries `etaSeconds`: the time left at the job's speed over the last 15 seconds, `null` until a second of progress has been measured (a loudness pass, a seek) and once it is done. The app shows it as `37% · 2:05 left` in the @@ -213,6 +216,15 @@ strings; dotted keys nest (`color.r=1`). `run` prints one JSON line per command (`{"line","id","ok","result"|"error"}`) and stops at the first failure unless `--keep-going`. `--save` writes back to `--project`; `--save-as path` writes elsewhere. +Media that is offline when it is needed (the file is missing, was made offline, or can't be read) +renders as the "media not found" placeholder. `export` and `render` then name each such item on +stderr (`item 8 a.mp4 (missing): /path/a.mp4`) and exit 1 after writing the output, so a moved or +unmounted media folder doesn't pass as a good render. `--allow-offline` accepts the placeholder +frames: the warning stays and the exit status is 0. The export result lists the same items as +`offlineMedia` (`[{item, name, path, status, error}]`, `status` as in `media.status`: `missing`, +`offline` or `unreadable`), and `[]` when every clip it drew was online; check before exporting with +`exec media.status` or `exec media.findMissing`. + `markers.exportCsv {path}` exports all markers in the active sequence, including colours hidden in the UI and markers outside In/Out, sorted by start then id. The UTF-8 CSV has a header and CRLF records; quotes, commas and embedded newlines are escaped. Columns are Sequence, Marker ID, Name, From e674be795ff7ace8936402f3d9e63a1a1a86fa10 Mon Sep 17 00:00:00 2001 From: Ikki Nishihara Date: Mon, 12 Oct 2026 03:47:15 +0900 Subject: [PATCH 240/260] =?UTF-8?q?fix:=20=E5=86=8D=E7=94=9F=E5=81=9C?= =?UTF-8?q?=E6=AD=A2=E5=BE=8C=E3=82=82=E9=9F=B3=E9=87=8F=E3=83=A1=E3=83=BC?= =?UTF-8?q?=E3=82=BF=E3=83=BC=E3=82=92=E6=9B=B4=E6=96=B0=20#1062?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/ui-egui/src/panels/mixer.rs | 4 +++ crates/ui-egui/tests/mixer_ui.rs | 44 ++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/crates/ui-egui/src/panels/mixer.rs b/crates/ui-egui/src/panels/mixer.rs index bcc1b42af..bf66c174f 100644 --- a/crates/ui-egui/src/panels/mixer.rs +++ b/crates/ui-egui/src/panels/mixer.rs @@ -119,6 +119,10 @@ pub fn poll_meters(app: &mut FilmcraftApp, ui: &egui::Ui) -> HashMap -90.0)) { ui.ctx().request_repaint(); } ui.data_mut(|d| d.insert_temp(id, (frame, st.clone()))); diff --git a/crates/ui-egui/tests/mixer_ui.rs b/crates/ui-egui/tests/mixer_ui.rs index e698d2bfb..61d51dbe1 100644 --- a/crates/ui-egui/tests/mixer_ui.rs +++ b/crates/ui-egui/tests/mixer_ui.rs @@ -21,12 +21,19 @@ struct Driver { impl Driver { fn demo() -> Self { + Self::demo_with_step_dt(None) + } + + fn demo_with_step_dt(step_dt: Option) -> 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 snapshots = std::env::var_os("FILMCRAFT_UI_SNAPSHOT_DIR").map(std::path::PathBuf::from); let mut b = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000); + if let Some(dt) = step_dt { + b = b.with_step_dt(dt); + } if snapshots.is_some() { b = b.wgpu(); } @@ -310,3 +317,40 @@ fn audio_gain_dialog_normalizes_the_selection() { assert!(pk["peakDb"].as_f64().unwrap().abs() < 1e-6, "normalized to 0 dB: {pk}"); assert!(d.ids("audioGain.").is_empty(), "dialog closed"); } + +/// #1062: after Stop, the meter must finish its release without pointer events, then let the +/// window sleep. Run only frames requested by egui; forcing frames would hide the original bug. +#[test] +fn paused_meters_repaint_without_input_and_settle_after_release() { + for workspace in ["Editing", "Audio"] { + let mut d = Driver::demo_with_step_dt(Some(0.1)); + d.ok("ui.set", json!({"workspace": workspace})); + d.ok("ui.playback", json!({"action": "play"})); + d.frames(10); + let prefix = if workspace == "Audio" { "mixer.Mix.meter" } else { "audioMeters.channel." }; + let playing = d.ok("ui.elements", json!({"prefix": prefix})); + let labels = |elements: &Value| elements.as_array().unwrap().iter().map(|e| e["label"].as_str().unwrap().to_string()).collect::>(); + let active = labels(&playing); + assert!(!active.is_empty(), "{workspace}: the meters must be visible"); + assert!(active.iter().any(|label| !label.contains("-90.0")), "{workspace}: the demo must feed the meters: {active:?}"); + d.ok("ui.playback", json!({"action": "stop"})); + assert!(!d.harness.state().playback.playing); + d.snapshot(&format!("meters-{workspace}-paused-start"), None); + // Harness::run follows immediate repaint requests, adding no mouse or keyboard input. + let steps = d.harness.run(); + // Inspect both values that drive the bars: a silent level alone must not strand its + // slower peak hold. No new frames or pointer events are injected to read this state. + let (_, meters): (u64, std::collections::HashMap>) = + d.harness.ctx.data(|data| data.get_temp(egui::Id::new("mixer-meters"))).unwrap(); + assert!(!meters.is_empty()); + assert!(meters.values().flatten().all(|meter| *meter == [-90.0; 2]), "{workspace}: meters stopped after {steps} requested frames: {meters:?}"); + assert_ne!( + d.harness.output().viewport_output[&egui::ViewportId::ROOT].repaint_delay, + std::time::Duration::ZERO, + "{workspace}: silent meters must not keep repainting" + ); + let quiet = d.ok("ui.elements", json!({"prefix": prefix})); + assert!(labels(&quiet).iter().all(|label| label.contains("-90.0")), "{workspace}: {:?}", labels(&quiet)); + d.snapshot(&format!("meters-{workspace}-paused-silent"), None); + } +} From 4c41755b83bfbf76318e19907b3b3563e99f4fcb Mon Sep 17 00:00:00 2001 From: Ikki Nishihara Date: Mon, 12 Oct 2026 04:12:35 +0900 Subject: [PATCH 241/260] =?UTF-8?q?fix(ui):=20=E5=80=A4=E3=82=B0=E3=83=A9?= =?UTF-8?q?=E3=83=95=E3=81=AE=E3=82=AD=E3=83=BC=E3=83=95=E3=83=AC=E3=83=BC?= =?UTF-8?q?=E3=83=A0=E8=A3=9C=E9=96=93=E3=83=A1=E3=83=8B=E3=83=A5=E3=83=BC?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0=20#962?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/ui-egui/src/panels/effect_controls.rs | 74 +++++++----- crates/ui-egui/tests/keyframes_ui.rs | 117 ++++++++++++++++++- 2 files changed, 160 insertions(+), 31 deletions(-) diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 6b071c4ca..7863df266 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -690,35 +690,46 @@ pub(crate) fn param_row( actions.push(("playhead.set".into(), json!({"time": tl.0}))); } resp.context_menu(|ui| { - ui.label(egui::RichText::new(tl!("Temporal Interpolation")).color(t.text_dim)); - for (label, key) in [ - (tl!("Linear"), "linear"), - (tl!("Bezier"), "bezier"), - (tl!("Auto Bezier"), "autoBezier"), - (tl!("Continuous Bezier"), "continuousBezier"), - (tl!("Hold"), "hold"), - (tl!("Ease In"), "easeIn"), - (tl!("Ease Out"), "easeOut"), - ] { - if ui.button(label).clicked() { - actions.push(( - "effects.setInterpolation".into(), - with_mask(json!({"clip": clip.0, "effect": eff_json, "param": pd.id, "mediaTime": k.time.0, "interpolation": key})), - )); - ui.close(); - } - } - ui.separator(); - if ui.button(tl!("Clear")).clicked() { - actions - .push(("effects.deleteKeyframe".into(), with_mask(json!({"clip": clip.0, "effect": eff_json, "param": pd.id, "mediaTime": k.time.0})))); - ui.close(); - } + keyframe_menu(app, ui, KeyframeRef { clip: clip.0, effect: idx, param: pd.id.to_string(), mask, time: k.time }, actions); }); } } } +/// Shared by the lane diamonds and graph points: commands use media time and preserve the mask. +fn keyframe_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, target: KeyframeRef, actions: &mut Vec<(String, Value)>) { + let mut params = json!({"clip": target.clip, "effect": target.effect, "param": target.param, "mediaTime": target.time.0}); + if let Some(mask) = target.mask { + params["mask"] = json!(mask); + } + ui.label(egui::RichText::new(tl!("Temporal Interpolation")).color(app.tokens.text_dim)); + for (label, key) in [ + (tl!("Linear"), "linear"), + (tl!("Bezier"), "bezier"), + (tl!("Auto Bezier"), "autoBezier"), + (tl!("Continuous Bezier"), "continuousBezier"), + (tl!("Hold"), "hold"), + (tl!("Ease In"), "easeIn"), + (tl!("Ease Out"), "easeOut"), + ] { + let response = ui.button(label); + app.auto.add(&format!("effectControls.keyframeMenu.{key}"), response.rect, label); + if response.clicked() { + let mut params = params.clone(); + params["interpolation"] = json!(key); + actions.push(("effects.setInterpolation".into(), params)); + ui.close(); + } + } + ui.separator(); + let response = ui.button(tl!("Clear")); + app.auto.add("effectControls.keyframeMenu.clear", response.rect, "Clear"); + if response.clicked() { + actions.push(("effects.deleteKeyframe".into(), params)); + ui.close(); + } +} + /// Lumetri Color panel: edits (or adds) the Lumetri effect on the selected clip, grouped by section. pub fn lumetri_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; @@ -1117,7 +1128,10 @@ pub(crate) fn graph_rows( 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 resp = ui.interact(r.expand(2.0), id, Sense::click_and_drag()); + resp.context_menu(|ui| { + keyframe_menu(app, ui, KeyframeRef { clip: clip.0, effect: idx, param: pd.id.to_string(), mask, time: k.time }, actions); + }); 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), @@ -1138,11 +1152,11 @@ pub(crate) fn graph_rows( 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() { + if hr.dragged_by(egui::PointerButton::Primary) { 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() { + if hr.drag_stopped_by(egui::PointerButton::Primary) { 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" }; @@ -1152,13 +1166,13 @@ pub(crate) fn graph_rows( )); } } - p.circle_filled(c, 4.5, if resp.dragged() { t.hot_text } else { t.plot_handle }); - if resp.dragged() { + p.circle_filled(c, 4.5, if resp.dragged_by(egui::PointerButton::Primary) { t.hot_text } else { t.plot_handle }); + if resp.dragged_by(egui::PointerButton::Primary) { 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() { + if resp.drag_stopped_by(egui::PointerButton::Primary) { 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 }; diff --git a/crates/ui-egui/tests/keyframes_ui.rs b/crates/ui-egui/tests/keyframes_ui.rs index fe12275f6..bf29cb62d 100644 --- a/crates/ui-egui/tests/keyframes_ui.rs +++ b/crates/ui-egui/tests/keyframes_ui.rs @@ -9,7 +9,7 @@ use std::sync::mpsc::{Sender, channel}; use egui_kittest::Harness; use filmcraft_engine::Session; -use filmcraft_project::ClipId; +use filmcraft_project::{ClipId, Interpolation, Param}; use filmcraft_time::Tick; use filmcraft_ui_egui::FilmcraftApp; use filmcraft_ui_egui::control::ControlRequest; @@ -105,6 +105,17 @@ impl Driver { let p = it.effect("motion").unwrap().param("scale").unwrap(); (p.keyframes.iter().map(|k| k.time.0).collect(), p.f64_at(it.source_time_at(s.playhead()))) } + fn parameter(&self, clip: &Clip, effect: &str, param: &str) -> Param { + self.harness.state().session.active_sequence().unwrap().find_item(ClipId(clip.id)).unwrap().1.effect(effect).unwrap().param(param).unwrap().clone() + } + fn undo_len(&mut self) -> usize { + self.exec("history.list", json!({}))["undo"].as_array().unwrap().len() + } + fn right_click(&mut self, id: &str) { + self.rect(id); + self.ok("ui.click", json!({"id": id, "button": "right"})); + self.frames(3); + } fn shot(&mut self, name: &str) { let Some(dir) = self.shots.clone() else { return }; for _ in 0..40 { @@ -313,3 +324,107 @@ fn effect_controls_divider_and_scrolling_anywhere() { 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:?}"); } + +/// #962: graph points need the same interpolation menu as lane diamonds, including either +/// coordinate of Transform Position. Opening a menu never changes the value or creates an undo. +#[test] +fn graph_keyframes_offer_interpolation_and_clear_in_one_undo_step() { + for (effect, param, values, component) in [ + ("motion", "scale", [json!(40.0), json!(80.0)], ""), + ("motion", "position", [json!([400.0, 300.0]), json!([800.0, 500.0])], ".x"), + ("transform", "position", [json!([400.0, 300.0]), json!([800.0, 500.0])], ".x"), + ("transform", "position", [json!([400.0, 300.0]), json!([800.0, 500.0])], ".y"), + ] { + let (mut d, clip) = Driver::demo(); + if effect == "transform" { + d.exec("effects.apply", json!({"clips": [clip.id], "effect": effect})); + for intrinsic in ["motion", "opacity", "time_remap"] { + d.click(&format!("effectControls.effect.{intrinsic}")); + } + } + for (seconds, value) in [0.5, 2.5].into_iter().zip(values) { + d.seek(seconds); + d.exec("effects.addKeyframe", json!({"clip": clip.id, "effect": effect, "param": param})); + d.exec("effects.setParam", json!({"clip": clip.id, "effect": effect, "param": param, "value": value})); + } + d.click(&format!("effectControls.{effect}.{param}.graphs")); + let before = d.parameter(&clip, effect, param); + let undo = d.undo_len(); + let target = format!("effectControls.{param}.graph.keyframe.{}{component}", before.keyframes[0].time.0); + for (key, interpolation) in [ + ("bezier", Interpolation::Bezier), + ("autoBezier", Interpolation::AutoBezier), + ("continuousBezier", Interpolation::ContinuousBezier), + ("hold", Interpolation::Hold), + ("easeIn", Interpolation::EaseIn), + ("easeOut", Interpolation::EaseOut), + ("linear", Interpolation::Linear), + ] { + d.right_click(&target); + assert_eq!(d.parameter(&clip, effect, param), before, "opening the graph menu cannot edit {effect}.{param}{component}"); + assert_eq!(d.undo_len(), undo); + if key == "easeIn" && effect == "transform" { + d.shot(&format!("graph-menu-{effect}-{param}{component}")); + } + d.click(&format!("effectControls.keyframeMenu.{key}")); + let changed = d.parameter(&clip, effect, param); + assert_eq!(changed.keyframes[0].interp, interpolation); + assert_eq!(changed.keyframes[0].time, before.keyframes[0].time); + assert_eq!(changed.keyframes[0].value, before.keyframes[0].value); + assert_eq!(changed.keyframes[1], before.keyframes[1]); + assert_eq!(d.undo_len(), undo + 1); + assert!(d.find("effectControls.keyframeMenu.clear").is_none(), "choosing interpolation closes the menu"); + d.exec("edit.undo", json!({})); + assert_eq!(d.parameter(&clip, effect, param), before); + d.exec("edit.redo", json!({})); + assert_eq!(d.parameter(&clip, effect, param), changed); + d.exec("edit.undo", json!({})); + } + d.right_click(&target); + d.click("effectControls.keyframeMenu.clear"); + let remaining = d.parameter(&clip, effect, param); + assert_eq!(remaining.keyframes, before.keyframes[1..]); + assert_eq!(d.undo_len(), undo + 1); + d.exec("edit.undo", json!({})); + assert_eq!(d.parameter(&clip, effect, param), before); + } +} + +#[test] +fn mask_keyframe_menus_preserve_the_effect_and_other_masks() { + let (mut d, clip) = Driver::demo(); + d.ok("ui.set", json!({"workspace": "Effects"})); + for intrinsic in ["motion", "time_remap"] { + d.click(&format!("effectControls.effect.{intrinsic}")); + } + for mask in 0..2 { + d.exec("masks.add", json!({"clip": clip.id, "effect": "opacity", "shape": "ellipse"})); + for (seconds, value) in [(0.5, 20.0), (2.5, 60.0)] { + d.seek(seconds); + d.exec("effects.addKeyframe", json!({"clip": clip.id, "effect": "opacity", "mask": mask, "param": "feather"})); + d.exec("effects.setParam", json!({"clip": clip.id, "effect": "opacity", "mask": mask, "param": "feather", "value": value})); + } + } + let effect = |d: &Driver| d.harness.state().session.active_sequence().unwrap().find_item(ClipId(clip.id)).unwrap().1.effect("opacity").unwrap().clone(); + let before = effect(&d); + let time = before.masks[0].param("feather").unwrap().keyframes[0].time.0; + let undo = d.undo_len(); + for (target, key, interpolation) in [ + (format!("effectControls.opacity.mask0.feather.keyframe.{time}"), "easeOut", Interpolation::EaseOut), + (format!("effectControls.feather.graph.keyframe.{time}"), "easeIn", Interpolation::EaseIn), + ] { + if key == "easeIn" { + d.click("effectControls.opacity.mask0.feather.graphs"); + } + d.right_click(&target); + assert_eq!(effect(&d), before, "opening the menu cannot change any mask"); + assert_eq!(d.undo_len(), undo); + d.click(&format!("effectControls.keyframeMenu.{key}")); + let mut expected = before.clone(); + expected.masks[0].param_mut("feather").unwrap().keyframes[0].interp = interpolation; + assert_eq!(effect(&d), expected, "only the first mask's first feather keyframe changes"); + assert_eq!(d.undo_len(), undo + 1); + d.exec("edit.undo", json!({})); + assert_eq!(effect(&d), before); + } +} From cfe0c751e947604f66e3a7d42f5701c32aacaf48 Mon Sep 17 00:00:00 2001 From: seif <110499010+seif9116@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:35:01 -0600 Subject: [PATCH 242/260] #395: Arabic and Hebrew in the interface: fallback fonts, captions in reading order The interface had no face for Arabic or Hebrew, so caption text, track and media names showed missing-glyph boxes in the Text panel and on the timeline while the Program monitor drew them. crates/ui-egui/src/rtl.rs adds the craft-fonts Arab / Hebr faces, else one installed face per script (Noto Sans Arabic, SF Arabic, Segoe UI...), as the last fallbacks of every UI font family, as cjk.rs does for Chinese. egui already shapes each font run with harfrust, so the letters join, but it lays runs out left to right with no bidi pass and multi-word Arabic read backwards. rtl::Lines lays captions out in reading order: lines are broken on the logical text as egui wraps it, each line is reordered with unicode-bidi (UAX #9, as crates/text does for titles) and every right-to-left word is its own layout section for egui's shaper. Caption blocks on the timeline and the Text panel caption list use it; a caption being edited keeps egui's logical order, since TextEdit places the caret by character index. Left-to-right text is laid out exactly as before. --- Cargo.lock | 1 + crates/ui-egui/Cargo.toml | 2 + crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/panels/text.rs | 36 +- .../ui-egui/src/panels/timeline_captions.rs | 9 +- crates/ui-egui/src/rtl.rs | 415 ++++++++++++++++++ crates/ui-egui/src/theme.rs | 8 +- docs/captions.md | 5 +- docs/contributing.md | 8 + docs/gaps.md | 4 +- docs/localization-parity.md | 9 +- 11 files changed, 478 insertions(+), 20 deletions(-) create mode 100644 crates/ui-egui/src/rtl.rs diff --git a/Cargo.lock b/Cargo.lock index 9d1aa3311..5bd215a96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2303,6 +2303,7 @@ dependencies = [ "serde", "serde_json", "toml 1.1.6+spec-1.1.0", + "unicode-bidi", "web-time", ] diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 950c22acd..b63880d4e 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -33,6 +33,8 @@ serde_json = { workspace = true } log = { workspace = true } rayon = { workspace = true } web-time = "1" +# Unicode Bidirectional Algorithm for Arabic and Hebrew captions in reading order (MIT OR Apache-2.0). +unicode-bidi = "0.3.18" # ArtCraft wordmark (PNG) for the About dialog and Home screen. image = { version = "0.25", default-features = false, features = ["png"] } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 438112a29..0d26c5321 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -48,6 +48,7 @@ pub mod panels; pub mod perf; #[cfg(not(target_arch = "wasm32"))] pub mod play_ahead; +mod rtl; pub mod scrub; pub mod source_playback; pub mod state; diff --git a/crates/ui-egui/src/panels/text.rs b/crates/ui-egui/src/panels/text.rs index 842000955..2626bf488 100644 --- a/crates/ui-egui/src/panels/text.rs +++ b/crates/ui-egui/src/panels/text.rs @@ -233,14 +233,18 @@ fn captions(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } let key = egui::Id::new(("cap-text", c.id.0)); let mut buf = ui.data(|d| d.get_temp::(key)).unwrap_or_else(|| c.text.clone()); - let resp = ui.add( - egui::TextEdit::multiline(&mut buf) - .id(key.with("te")) - .desired_rows(1) - .desired_width(ui.available_width()) - .font(Tokens::ui(12.5)) - .frame(egui::Frame::NONE), - ); + let resp = if crate::rtl::has_rtl(&buf) && !ui.memory(|m| m.has_focus(key.with("te"))) { + reading_order(ui, &buf, key.with("te")) + } else { + ui.add( + egui::TextEdit::multiline(&mut buf) + .id(key.with("te")) + .desired_rows(1) + .desired_width(ui.available_width()) + .font(Tokens::ui(12.5)) + .frame(egui::Frame::NONE), + ) + }; app.auto.add(&format!("text.captions.{}.text", c.id.0), resp.rect, "Caption text"); if resp.has_focus() { ui.data_mut(|d| d.insert_temp(key, buf.clone())); @@ -277,6 +281,22 @@ fn captions(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { run(app, ui, actions); } +/// A caption's text in reading order ([`crate::rtl::Lines`]) in place of its `TextEdit`, which +/// shows right-to-left words in logical order. A click hands focus to the `TextEdit` (`id`), so the +/// text is edited in logical order and shown in reading order again when the field loses focus. +fn reading_order(ui: &mut egui::Ui, text: &str, id: egui::Id) -> egui::Response { + let margin = vec2(4.0, 2.0); // a TextEdit's own margin, so the text does not move on focus + let color = ui.visuals().widgets.inactive.text_color(); + let width = ui.available_width(); + let lines = crate::rtl::Lines::new(ui.painter(), text, Tokens::ui(12.5), color, (width - 2.0 * margin.x).max(0.0)); + let (rect, resp) = ui.allocate_exact_size(vec2(width, lines.size.y + 2.0 * margin.y), Sense::click()); + lines.paint(ui.painter(), rect.shrink2(margin), color); + if resp.clicked() { + ui.memory_mut(|m| m.request_focus(id)); + } + resp.on_hover_cursor(egui::CursorIcon::Text) +} + fn transcript(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; if app.session.active_sequence().is_none() { diff --git a/crates/ui-egui/src/panels/timeline_captions.rs b/crates/ui-egui/src/panels/timeline_captions.rs index 781c7ade5..811cb1007 100644 --- a/crates/ui-egui/src/panels/timeline_captions.rs +++ b/crates/ui-egui/src/panels/timeline_captions.rs @@ -148,10 +148,11 @@ pub fn paint(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, area: Re } if r.width() > 14.0 { let text = filmcraft_project::plain_text(&c.text).replace('\n', " "); - p.with_clip_rect(r.shrink2(vec2(4.0, 0.0)).intersect(lane)).text( - pos2(r.min.x + 5.0, r.center().y), - Align2::LEFT_CENTER, - text, + // in reading order; Arabic and Hebrew start at the block's right edge + crate::rtl::paint_line( + &p.with_clip_rect(r.shrink2(vec2(4.0, 0.0)).intersect(lane)), + Rect::from_min_max(pos2(r.min.x + 5.0, r.min.y), pos2(r.max.x - 5.0, r.max.y)), + &text, Tokens::ui(11.0), if tr.enabled { Color32::WHITE } else { t.text_dim }, ); diff --git a/crates/ui-egui/src/rtl.rs b/crates/ui-egui/src/rtl.rs new file mode 100644 index 000000000..7b71769cf --- /dev/null +++ b/crates/ui-egui/src/rtl.rs @@ -0,0 +1,415 @@ +//! Right-to-left scripts (Arabic, Hebrew) in the interface: fallback fonts and reading order. +//! +//! Fonts: like [`crate::cjk`], the craft-fonts faces for these scripts (`Arab`, `Hebr`) become the +//! last fallbacks of every UI font family. A build without them looks up one installed face per +//! script once (Noto Sans Arabic, SF Arabic, Segoe UI…) and reuses it across theme changes. +//! +//! Reading order: egui shapes each font run with harfrust, so a word's letters join and run right +//! to left, but it places the runs left to right in logical order with no bidi pass: in +//! "كلمة أخرى" the second word comes out first. [`Lines`] lays out document text (captions) in +//! reading order for display. Lines are broken on the logical text as egui wraps it, each line's +//! runs are put in visual order by the Unicode Bidirectional Algorithm (UAX #9, as the text engine +//! does for titles), and every right-to-left word becomes its own layout section, which egui's +//! shaper draws right to left. Text being edited keeps egui's logical order: `TextEdit` maps the +//! caret by character index, so a reordered galley would put it in the wrong place. + +use std::borrow::Cow; +use std::ops::Range; +use std::sync::{Arc, OnceLock}; + +use egui::epaint::text::{ByteIndex, LayoutJob, LayoutSection, TextFormat}; +use egui::{Color32, FontData, FontDefinitions, FontId, Galley, Painter, Rect, Vec2, pos2}; +use filmcraft_text::fonts::{CraftFont, Face}; +use unicode_bidi::{BidiClass, BidiInfo, bidi_class}; + +/// A right-to-left script: its ISO 15924 code (as in the craft-fonts manifest), letters a face +/// must cover, and installed families to prefer, best first. +struct Script { + code: &'static str, + sample: &'static str, + preferred: &'static [&'static str], +} + +const SCRIPTS: &[Script] = &[ + Script { + code: "Arab", + sample: "ابتثجحخدذرزسشصضطظعغفقكلمنهويءآأؤإئةى،؟", + preferred: &[ + "Noto Sans Arabic UI", + "Noto Sans Arabic", + "SF Arabic", + "Geeza Pro", + "Segoe UI", + "Noto Naskh Arabic UI", + "Noto Naskh Arabic", + "Tahoma", + "Arial", + "DejaVu Sans", + ], + }, + Script { + code: "Hebr", + sample: "אבגדהוזחטיכךלמםנןסעפףצץקרשת", + preferred: &["Noto Sans Hebrew", "SF Hebrew", "Arial Hebrew", "Segoe UI", "Arial", "Tahoma", "DejaVu Sans"], + }, +]; + +/// The craft-fonts faces for right-to-left scripts (empty unless built with `CRAFT_FONTS_DIR`). +pub(crate) fn craft_fonts() -> impl Iterator { + filmcraft_text::fonts::CRAFT_FONTS.iter().filter(|f| f.scripts.iter().any(|s| SCRIPTS.iter().any(|x| x.code == *s))) +} + +/// Installed faces for the scripts craft-fonts does not cover, looked up once per process (native +/// builds only). A face that covers several scripts (Segoe UI) is added once. +fn system_fonts() -> &'static [(String, Arc)] { + static FONTS: OnceLock)>> = OnceLock::new(); + FONTS.get_or_init(|| { + if cfg!(target_arch = "wasm32") { + return Vec::new(); + } + let missing: Vec<&Script> = SCRIPTS.iter().filter(|s| !craft_fonts().any(|f| f.scripts.contains(&s.code))).collect(); + if missing.is_empty() { + return Vec::new(); + } + 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 by_weight = |f: &&Arc| f.info.weight.abs_diff(400); + let mut chosen: Vec<&Arc> = Vec::new(); + let mut out = Vec::new(); + for s in missing { + if chosen.iter().any(|f| f.covers_text(s.sample)) { + continue; + } + let covers = |f: &&Arc| f.covers_text(s.sample); + let preferred = + s.preferred.iter().find_map(|name| faces.iter().filter(|f| f.info.family.eq_ignore_ascii_case(name)).filter(covers).min_by_key(by_weight)); + let Some(face) = preferred.or_else(|| faces.iter().filter(covers).min_by_key(by_weight)) else { + continue; + }; + let Some(data) = face.data() else { + continue; + }; + let font = FontData { font: Cow::Owned(data), index: face.info.index, tweak: Default::default() }; + out.push((format!("system-{}", s.code.to_lowercase()), Arc::new(font))); + chosen.push(face); + } + out + }) +} + +/// Append the right-to-left faces (craft-fonts, else installed ones) after the other fallbacks of +/// every font family. Their letters are in no other UI font, so their place among the fallbacks +/// does not matter, and Latin text keeps the interface fonts. Installed faces come last, after +/// the craft-fonts faces: one may be a broad face (DejaVu Sans, Segoe UI) that should not take +/// symbols the craft-fonts faces draw. +pub(crate) fn install(fonts: &mut FontDefinitions) { + let mut names = Vec::new(); + for f in craft_fonts() { + let name = format!("craft:{} {}", f.family, f.style); + fonts.font_data.insert(name.clone(), Arc::new(FontData::from_static(f.bytes))); + names.push(name); + } + for (name, font) in system_fonts() { + fonts.font_data.insert(name.clone(), font.clone()); + names.push(name.clone()); + } + for stack in fonts.families.values_mut() { + for name in &names { + if !stack.contains(name) { + stack.push(name.clone()); + } + } + } +} + +/// Whether `name` is an installed right-to-left face added by [`install`]. +#[cfg(test)] +pub(crate) fn is_system_face(name: &str) -> bool { + system_fonts().iter().any(|(n, _)| n == name) +} + +/// Whether `text` has a right-to-left letter (Arabic, Hebrew…), the only text [`Lines`] reorders. +pub(crate) fn has_rtl(text: &str) -> bool { + text.chars().any(|c| matches!(bidi_class(c), BidiClass::R | BidiClass::AL)) +} + +/// Characters that stay together, in logical order, in a right-to-left word: letters, marks and +/// joiners. egui shapes such a word as one right-to-left run. +fn in_word(c: char) -> bool { + matches!(bidi_class(c), BidiClass::R | BidiClass::AL | BidiClass::NSM | BidiClass::BN) +} + +/// A bracket shown right to left takes its mirrored glyph (UAX #9 rule L4). +fn mirror(c: char) -> char { + match c { + '(' => ')', + ')' => '(', + '[' => ']', + ']' => '[', + '{' => '}', + '}' => '{', + '<' => '>', + '>' => '<', + '«' => '»', + '»' => '«', + '‹' => '›', + '›' => '‹', + _ => c, + } +} + +/// The layout sections of one line of `bidi.text` (byte range `line`, inside one paragraph), left +/// to right. A left-to-right run stays whole, except that Arabic-Indic digits get a section each +/// so the shaper cannot run them right to left. A right-to-left run is split into words +/// ([`in_word`]) and single other characters (mirrored), listed in reverse: egui shapes each word +/// right to left itself. +fn line_sections(bidi: &BidiInfo<'_>, line: Range) -> Vec { + let text = bidi.text; + let Some(whole) = text.get(line.clone()).filter(|s| !s.is_empty()) else { + return Vec::new(); + }; + let Some(para) = bidi.paragraphs.iter().find(|p| p.range.start <= line.start && line.end <= p.range.end) else { + return vec![whole.to_owned()]; + }; + let (levels, runs) = bidi.visual_runs(para, line); + let mut out = Vec::new(); + for run in runs { + let Some(s) = text.get(run.clone()) else { + continue; + }; + if levels.get(run.start).is_some_and(|l| l.is_rtl()) { + let mut units = Vec::new(); + let mut word = String::new(); + for c in s.chars() { + if in_word(c) { + word.push(c); + continue; + } + if !word.is_empty() { + units.push(std::mem::take(&mut word)); + } + units.push(mirror(c).to_string()); + } + if !word.is_empty() { + units.push(word); + } + out.extend(units.into_iter().rev()); + } else { + let mut plain = String::new(); + for c in s.chars() { + if bidi_class(c) == BidiClass::AN { + if !plain.is_empty() { + out.push(std::mem::take(&mut plain)); + } + out.push(c.to_string()); + } else { + plain.push(c); + } + } + if !plain.is_empty() { + out.push(plain); + } + } + } + out +} + +/// Byte ranges of the lines egui broke `text` into in `galley` (newlines left out), or None when +/// the rows do not account for every character. +fn line_ranges(text: &str, galley: &Galley) -> Option>> { + let bounds: Vec = text.char_indices().map(|(i, _)| i).chain(std::iter::once(text.len())).collect(); + let mut out = Vec::with_capacity(galley.rows.len()); + let mut at = 0usize; + for row in &galley.rows { + let end = at.checked_add(row.glyphs.len())?; + out.push(*bounds.get(at)?..*bounds.get(end)?); + at = end.checked_add(usize::from(row.ends_with_newline))?; + } + (at == bounds.len().saturating_sub(1)).then_some(out) +} + +/// Byte ranges of the lines of `text` between newlines. +fn paragraph_ranges(text: &str) -> Vec> { + let mut out = Vec::new(); + let mut start = 0; + for (i, c) in text.char_indices() { + if c == '\n' { + out.push(start..i); + start = i + 1; + } + } + out.push(start..text.len()); + out +} + +/// Document text laid out for display in reading order, one galley per line. +pub(crate) struct Lines { + rows: Vec>, + /// The text starts right to left (its first strong letter is Arabic or Hebrew): lines align right. + pub rtl: bool, + pub size: Vec2, +} + +impl Lines { + /// Lay out `text` in `font`, wrapped at `wrap_width` (`f32::INFINITY`: only at `\n`). Text + /// without right-to-left letters is laid out as egui always does, in one galley. + pub fn new(painter: &Painter, text: &str, font: FontId, color: Color32, wrap_width: f32) -> Self { + let logical = painter.layout(text.to_owned(), font.clone(), color, wrap_width); + if !has_rtl(text) { + return Self { size: logical.size(), rows: vec![logical], rtl: false }; + } + let bidi = BidiInfo::new(text, None); + let rtl = bidi.paragraphs.first().is_some_and(|p| p.level.is_rtl()); + let lines = line_ranges(text, &logical).unwrap_or_else(|| paragraph_ranges(text)); + let rows: Vec> = lines + .into_iter() + .map(|line| { + let mut job = LayoutJob::default(); + for s in line_sections(&bidi, line) { + let start = job.text.len(); + job.text.push_str(&s); + // pushed, not appended: `append` merges sections of equal format, and each + // right-to-left word must be shaped on its own + job.sections.push(LayoutSection { + leading_space: 0.0, + byte_range: ByteIndex(start)..ByteIndex(job.text.len()), + format: TextFormat::simple(font.clone(), color), + }); + } + if job.sections.is_empty() { painter.layout_no_wrap(String::new(), font.clone(), color) } else { painter.layout_job(job) } + }) + .collect(); + let size = rows.iter().fold(Vec2::ZERO, |s, g| Vec2::new(s.x.max(g.size().x), s.y + g.size().y)); + Self { rows, rtl, size } + } + + /// Paint the lines down from the top of `rect`: from its left edge, or from its right edge when + /// [`Self::rtl`]. + pub fn paint(&self, painter: &Painter, rect: Rect, color: Color32) { + let mut y = rect.min.y; + for g in &self.rows { + let x = if self.rtl { rect.max.x - g.size().x } else { rect.min.x }; + painter.galley(pos2(x, y), g.clone(), color); + y += g.size().y; + } + } +} + +/// Paint one line of document text vertically centred in `rect`, in reading order: from the left +/// edge, or from the right edge when it reads right to left, so a clipped line still shows its start. +pub(crate) fn paint_line(painter: &Painter, rect: Rect, text: &str, font: FontId, color: Color32) { + let lines = Lines::new(painter, text, font, color, f32::INFINITY); + let top = rect.center().y - lines.size.y / 2.0; + lines.paint(painter, Rect::from_min_max(pos2(rect.min.x, top), pos2(rect.max.x, top + lines.size.y)), color); +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The sections of each line of `text` (split at newlines), left to right. + fn visual(text: &str) -> Vec> { + let bidi = BidiInfo::new(text, None); + paragraph_ranges(text).into_iter().map(|l| line_sections(&bidi, l)).collect() + } + + #[test] + fn rtl_words_are_listed_last_word_first() { + assert_eq!(visual("كلمة أخرى"), [["أخرى", " ", "كلمة"]]); + assert_eq!(visual("שלום עולם"), [["עולם", " ", "שלום"]]); + } + + #[test] + fn marks_and_joiners_stay_in_their_word() { + // fatha, shadda and a zero-width non-joiner belong to the letters around them + assert_eq!(visual("كَتَبَ مُحَمَّد"), [["مُحَمَّد", " ", "كَتَبَ"]]); + assert_eq!(visual("می\u{200c}خواهم بود"), [["بود", " ", "می\u{200c}خواهم"]]); + } + + #[test] + fn latin_and_numbers_inside_rtl_keep_their_order() { + assert_eq!(visual("قال FilmCraft 0.6 جيد"), [["جيد", " ", "FilmCraft 0.6", " ", "قال"]]); + // Arabic-Indic digits read left to right, one section each so the shaper keeps them so + assert_eq!(visual("سنة ١٤٤٥"), [["١", "٤", "٤", "٥", " ", "سنة"]]); + } + + #[test] + fn rtl_punctuation_and_brackets() { + assert_eq!(visual("نعم، (نص)"), [["(", "نص", ")", " ", "،", "نعم"]]); + assert_eq!(visual("لماذا؟"), [["لماذا؟"]]); + } + + #[test] + fn ltr_paragraph_with_an_rtl_phrase() { + assert_eq!(visual("Lesson كلمة أخرى end"), [["Lesson ", "أخرى", " ", "كلمة", " end"]]); + assert!(!has_rtl("Lesson 3: (draft)")); + assert!(has_rtl("Lesson كلمة")); + } + + #[test] + fn each_line_is_reordered_on_its_own() { + assert_eq!(visual("سطر أول\nسطر ثان"), [["أول", " ", "سطر"], ["ثان", " ", "سطر"]]); + assert_eq!(visual("نص\n\nEnglish"), [vec!["نص"], vec![], vec!["English"]]); + } + + #[test] + fn hostile_ranges_give_nothing() { + let text = "كلمة"; + let bidi = BidiInfo::new(text, None); + assert!(line_sections(&bidi, 1..3).is_empty(), "not on a char boundary"); + assert!(line_sections(&bidi, 0..99).is_empty(), "past the end"); + #[allow(clippy::reversed_empty_ranges)] + let backwards = 4..2; + assert!(line_sections(&bidi, backwards).is_empty()); + } + + /// With an Arabic face installed, captions lay out with real glyphs in reading order: in + /// "كلمة أخرى" the whole second word draws left of the first, its last letter leftmost. + #[test] + fn lines_draw_arabic_in_reading_order() { + let ctx = egui::Context::default(); + let mut fonts = FontDefinitions::default(); + install(&mut fonts); + if !craft_fonts().any(|f| f.scripts.contains(&"Arab")) && !system_fonts().iter().any(|(name, _)| name == "system-arab") { + eprintln!("SKIPPED: no craft-fonts or installed Arabic font"); + return; + } + ctx.set_fonts(fonts); + ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear(); + let painter = Painter::new(ctx.clone(), egui::LayerId::background(), Rect::EVERYTHING); + let font = FontId::proportional(13.0); + let lines = Lines::new(&painter, "كلمة أخرى", font.clone(), Color32::WHITE, f32::INFINITY); + assert!(lines.rtl); + let row = lines.rows.first().and_then(|g| g.rows.first()).map(|r| r.glyphs.clone()).unwrap_or_default(); + // egui shapes each word on its own (letters joined, in visual order) and pads the row with + // empty glyphs so every char keeps a cursor position: only drawn glyphs count here + let drawn: Vec<_> = row.iter().filter(|g| !g.uv_rect.is_nothing()).collect(); + let order = drawn.iter().map(|g| g.chr).collect::(); + let xs = |word: &str| drawn.iter().filter(|g| word.contains(g.chr)).map(|g| g.pos.x).collect::>(); + let (first, second) = (xs("كلمة"), xs("أخرى")); + assert!(!first.is_empty() && !second.is_empty(), "{order:?}"); + assert!(second.iter().fold(f32::MIN, |a, &b| a.max(b)) < first.iter().fold(f32::MAX, |a, &b| a.min(b)), "{order:?}"); + let leftmost = drawn.iter().min_by(|a, b| a.pos.x.total_cmp(&b.pos.x)).map(|g| g.chr); + assert_eq!(leftmost, Some('ى'), "{order:?}"); + let missing = painter.layout_no_wrap("\u{fffd}".into(), font, Color32::WHITE); + let missing_uv = missing.rows.first().and_then(|r| r.glyphs.first()).map(|g| g.uv_rect); + assert!(drawn.iter().all(|g| Some(g.uv_rect) != missing_uv), "an Arabic letter drew as a missing glyph"); + ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear(); + } + + /// Lines broken by wrapping are each reordered: the first line holds the first words. + #[test] + fn line_ranges_follow_the_rows() { + let ctx = egui::Context::default(); + ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear(); + let painter = Painter::new(ctx.clone(), egui::LayerId::background(), Rect::EVERYTHING); + let text = "one two three\nfour"; + let galley = painter.layout(text.to_owned(), FontId::proportional(13.0), Color32::WHITE, 40.0); + let ranges = line_ranges(text, &galley).unwrap_or_default(); + assert!(ranges.len() >= 3, "{ranges:?}"); + let joined: Vec<&str> = ranges.iter().filter_map(|r| text.get(r.clone())).collect(); + assert_eq!(joined.concat().replace(' ', ""), "onetwothreefour"); + assert_eq!(ranges.last().and_then(|r| text.get(r.clone())), Some("four")); + ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear(); + } +} diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index a86834950..3cf300f59 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -415,7 +415,8 @@ pub fn font_families() -> Vec { } /// Install the Latin UI fonts, Chinese fallback (craft-fonts or an installed face), Japanese -/// craft-fonts and egui visuals. Document names keep their glyphs in every interface language. +/// craft-fonts, Arabic and Hebrew fallbacks (craft-fonts or installed faces, [`crate::rtl`]) and +/// egui visuals. Document names keep their glyphs in every interface language. pub fn install(ctx: &egui::Context, t: &Tokens) { let mut fonts = FontDefinitions::default(); fonts.font_data.insert("inter".into(), Arc::new(FontData::from_static(filmcraft_text::fonts::INTER_REGULAR))); @@ -430,6 +431,7 @@ pub fn install(ctx: &egui::Context, t: &Tokens) { // glyph forms; Chinese faces still cover the hanzi Japanese fonts lack add_craft_fonts(&mut fonts); crate::cjk::install(&mut fonts); + crate::rtl::install(&mut fonts); ctx.set_fonts(fonts); apply_visuals(ctx, t); } @@ -643,6 +645,8 @@ mod tests { ctx.fonts_mut(|fonts| { let defs = fonts.definitions().clone(); for (family, stack) in &defs.families { + // installed right-to-left faces (Hebrew is not in craft-fonts) come after the craft run + let stack: Vec<&String> = stack.iter().filter(|n| !crate::rtl::is_system_face(n)).collect(); let first = stack.iter().position(|n| n.starts_with("craft:")).unwrap_or(stack.len()); assert!(first > 0 && stack[first..].iter().all(|n| n.starts_with("craft:")), "{family:?}: {stack:?}"); let first_japanese = stack.iter().find(|n| n.starts_with("craft:BIZ") || n.starts_with("craft:Shippori")); @@ -666,7 +670,7 @@ mod tests { #[test] fn works_without_craft_fonts() { let ctx = installed(); - let n = filmcraft_text::fonts::craft_japanese().count() + crate::cjk::craft_fonts().count(); + let n = filmcraft_text::fonts::craft_japanese().count() + crate::cjk::craft_fonts().count() + crate::rtl::craft_fonts().count(); ctx.fonts_mut(|fonts| { let defs = fonts.definitions().clone(); assert_eq!(defs.font_data.keys().filter(|k| k.starts_with("craft:")).count(), n); diff --git a/docs/captions.md b/docs/captions.md index fd76483e2..871427d48 100644 --- a/docs/captions.md +++ b/docs/captions.md @@ -62,12 +62,13 @@ also splits captions. ## Timeline and Text panel - Caption rows: header with `C1…` badge, lock, output eye, name and format; caption blocks in - violet showing their text. Click selects (⌘/⇧ adds), drag moves, edge drag trims, double-click + violet showing their text (Arabic and Hebrew in reading order from the block's right edge). Click selects (⌘/⇧ adds), drag moves, edge drag trims, double-click opens the Text panel. Automation ids: `timeline.caption.`, `timeline.captionTrack.C1[.locked|.enabled]`. - **Text panel ▸ Captions**: search, track picker (also creates tracks of each format), add / split / merge / delete / export buttons, the list of segments with editable in/out timecodes - and text (committed when the field loses focus), and the track style strip (size, colour, box, + and text (committed when the field loses focus; Arabic and Hebrew show in reading order and are + edited in logical order while the field has focus), and the track style strip (size, colour, box, alignment, position). Automation ids: `text.tab.Captions`, `text.captions.add|split|merge|delete|export|search|track`, `text.captions..in|out|text|goto|row`, `text.captions.style.*`. diff --git a/docs/contributing.md b/docs/contributing.md index 245334b08..c7af17706 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -100,6 +100,14 @@ Web builds depend on the font faces selected by `crates/text/build.rs`; they can `theme::tests::chinese_names_render_in_every_theme_and_font_family` verifies Chinese glyphs in the timeline's proportional, monospace, medium and semibold families under all three themes. +Arabic and Hebrew work the same way (`crates/ui-egui/src/rtl.rs`): the `Arab` / `Hebr` craft-fonts +faces, else one installed face per script (for example Noto Sans Arabic, SF Arabic or Segoe UI), +are the last fallbacks of every UI font family. egui shapes each font run (letters join) but lays +runs out left to right with no bidi pass, so captions on the timeline and in the Text panel are laid +out in reading order by `rtl::Lines` (UAX #9 via unicode-bidi, each right-to-left word its own layout +section). A caption being edited shows egui's logical order, since `TextEdit` places the caret by +character index. + ## 3. Quality gates Every commit must pass all of these: diff --git a/docs/gaps.md b/docs/gaps.md index 564541067..a83525459 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -203,7 +203,9 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** - **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), +- **Missing:** Hindi, Arabic (no RTL interface layout or catalog; the interface has Arabic and Hebrew + fallback fonts and shows captions in reading order, but other names and fields keep egui's + left-to-right run order; Arabic titles reported broken, #395), 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. diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 6124d1c7a..763aa8295 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -25,8 +25,11 @@ Fonts come from [craft-fonts](https://github.com/storytold/craft-fonts) (`CRAFT_ 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). +is supported. The interface (egui) shapes each font run with harfrust and has Arabic and Hebrew +fallback fonts (`crates/ui-egui/src/rtl.rs`), but it has no bidi pass: only captions (timeline +blocks, the Text panel list when not being edited) are laid out in reading order, and the interface +itself is never mirrored right to left. The title engine (`crates/text`, harfrust + bidi) lays out +right to left, but Arabic titles are reported broken (#395). ## Status @@ -41,7 +44,7 @@ whole interface, one PR) at ~4–8 h per language for the catalog plus tests. | 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 | +| Arabic | ar | 0 (0%) | — | **no RTL interface layout**; fallback fonts and shaping in the interface, captions in reading order, other text in egui's left-to-right run order; titles broken (#395) | — | none | 40–70 h | | French | fr | 3,526 (same entries as Spanish, plus the macOS Settings item) | as Spanish | Latin (bundled fonts) | not recorded; welcome | partial (#498) | review | | Portuguese (Brazil) | pt-br | 348 (9.9%) | menus only | Latin | not recorded | menus only | 5–9 h + review | | Indonesian | id | 0 (0%) | — | Latin | — | none | 6–10 h + review | From 470730f1ed4247f73529cc90caee73d10fe3d288 Mon Sep 17 00:00:00 2001 From: Ikki Nishihara Date: Mon, 12 Oct 2026 04:34:22 +0900 Subject: [PATCH 243/260] =?UTF-8?q?fix(ui):=20=E3=83=91=E3=83=8D=E3=83=AB?= =?UTF-8?q?=E5=86=85=E5=AE=B9=E3=81=AE=E5=BE=8C=E3=81=AB=E3=83=95=E3=82=A9?= =?UTF-8?q?=E3=83=BC=E3=82=AB=E3=82=B9=E6=9E=A0=E3=82=92=E6=8F=8F=E7=94=BB?= =?UTF-8?q?=20#1075?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/ui-egui/src/dock.rs | 24 +++++++-------- crates/ui-egui/src/dock_ui.rs | 8 +++-- crates/ui-egui/tests/panels_ui.rs | 51 +++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 23b4b6cc1..70d822902 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -581,7 +581,9 @@ pub fn draw_group_chrome( t: &Tokens, reg: &mut crate::automation::Registry, ) -> Vec { - draw_group_chrome_details(ui, g, focused, seqs, t, reg).actions + let chrome = draw_group_chrome_details(ui, g, seqs, t, reg); + paint_group_focus(ui, g, focused, t); + chrome.actions } #[derive(Default)] @@ -590,14 +592,7 @@ pub struct Chrome { pub tabs: Vec<(PanelKind, Rect)>, } -pub fn draw_group_chrome_details( - ui: &mut egui::Ui, - g: &Group, - focused: PanelKind, - seqs: &SeqTabs, - t: &Tokens, - reg: &mut crate::automation::Registry, -) -> Chrome { +pub fn draw_group_chrome_details(ui: &mut egui::Ui, g: &Group, seqs: &SeqTabs, t: &Tokens, reg: &mut crate::automation::Registry) -> Chrome { let mut actions = Vec::new(); let mut tab_rects: Vec<(PanelKind, Rect)> = Vec::new(); let painter = ui.painter().clone(); @@ -836,10 +831,6 @@ pub fn draw_group_chrome_details( ui.ctx().data_mut(|d| d.remove::(list_id)); } } - // focus outline - if active_panel == Some(focused) { - painter.rect_stroke(g.rect, 0.0, Stroke::new(1.0, t.focus), StrokeKind::Inside); - } // clicking anywhere in the panel focuses it if let Some(p) = active_panel && ui.rect_contains_pointer(g.rect) @@ -850,6 +841,13 @@ pub fn draw_group_chrome_details( Chrome { actions, tabs: tab_rects } } +/// Paint once after the group's contents so child backgrounds cannot hide its focus (#1075). +pub(crate) fn paint_group_focus(ui: &egui::Ui, group: &Group, focused: PanelKind, tokens: &Tokens) { + if group.panels.get(group.active).copied() == Some(focused) { + ui.painter().rect_stroke(group.rect, 0.0, Stroke::new(1.0, tokens.focus), StrokeKind::Inside); + } +} + fn paint_widget_focus(painter: &egui::Painter, response: &egui::Response, tokens: &Tokens) { if response.has_focus() { painter.rect_stroke(response.rect.shrink(2.0), 2.0, Stroke::new(1.5, tokens.focus), StrokeKind::Inside); diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index d90888abf..4849c08d3 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -191,7 +191,7 @@ impl FilmcraftApp { let mut actions = vec![]; let mut targets = vec![]; for group in &groups { - let chrome = dock::draw_group_chrome_details(ui, group, self.ui.focused, &seqs, &t, &mut self.auto); + let chrome = dock::draw_group_chrome_details(ui, group, &seqs, &t, &mut self.auto); if let Some(target) = target(group, chrome.tabs, true) { targets.push(target); } @@ -205,6 +205,9 @@ impl FilmcraftApp { self.panel_body(ui, panel, group.content); } } + for group in &groups { + dock::paint_group_focus(ui, group, self.ui.focused, &t); + } let floating_order = self.floating_order(&ctx); for index in floating_order.into_iter().filter(|_| maximized.is_none()) { if let Some(target) = self.floating_group(&ctx, index, &seqs, &mut actions) { @@ -279,7 +282,7 @@ impl FilmcraftApp { panels: floating.panels.clone(), active: floating.active, }; - let chrome = dock::draw_group_chrome_details(ui, &group, self.ui.focused, seqs, &t, &mut self.auto); + let chrome = dock::draw_group_chrome_details(ui, &group, seqs, &t, &mut self.auto); actions.extend(chrome.actions); if let Some(active) = group.panels.get(group.active).copied() { self.panel_body(ui, active, group.content); @@ -307,6 +310,7 @@ impl FilmcraftApp { if next != rect { let _ = self.dock_command(ctx, Action::MoveFloating { panel, rect: [next.min.x, next.min.y, next.width(), next.height()] }); } + dock::paint_group_focus(ui, &group, self.ui.focused, &t); output = target(&group, chrome.tabs, false); }); output diff --git a/crates/ui-egui/tests/panels_ui.rs b/crates/ui-egui/tests/panels_ui.rs index 8f1b10136..da3fb47b6 100644 --- a/crates/ui-egui/tests/panels_ui.rs +++ b/crates/ui-egui/tests/panels_ui.rs @@ -463,3 +463,54 @@ fn scopes_of_colour_bars() { assert!(angles.iter().any(|x| (x - a).abs() < 1.5), "{} at {a:.1}° not in {angles:?}", t.name); } } + +/// #1075: child backgrounds must not cover the focus outline shared with Program. Check the +/// rendered primitives, including Media Browser's parent group, in both requested themes. +#[test] +fn focused_panel_outline_is_not_covered_by_child_backgrounds() { + fn rectangles<'a>(shape: &'a egui::Shape, clip: egui::Rect, out: &mut Vec<(&'a egui::epaint::RectShape, egui::Rect)>) { + match shape { + egui::Shape::Rect(rect) => out.push((rect, clip)), + egui::Shape::Vec(shapes) => shapes.iter().for_each(|shape| rectangles(shape, clip, out)), + _ => {} + } + } + let mut d = Driver::demo(); + for theme in ["dark", "light"] { + d.ok("ui.set", json!({"theme": theme})); + for panel in ["Program", "Text", "LumetriScopes", "EssentialSound", "Timeline", "TextToSpeech", "MediaBrowser"] { + d.ok("ui.panel.show", json!({"panel": panel})); + d.ok("ui.set", json!({"focused": panel})); + d.frames(3); + d.snapshot(&format!("focus-{theme}-{panel}"), None); + let mut rects = Vec::new(); + for shape in &d.harness.output().shapes { + rectangles(&shape.shape, shape.clip_rect, &mut rects); + } + let focus = d.harness.state().tokens.focus; + let outlines: Vec<_> = rects + .iter() + .enumerate() + .filter(|(_, (r, _))| r.stroke.color == focus && r.stroke.width == 1.0 && r.rect.width() > 100.0 && r.rect.height() > 80.0) + .collect(); + assert_eq!(outlines.len(), 1, "{theme} {panel}: one focus outline for the whole parent group"); + let (at, (outline, _)) = outlines[0]; + let bounds = outline.rect; + let strips = [ + egui::Rect::from_min_max(bounds.min, egui::pos2(bounds.max.x, bounds.min.y + 1.0)), + egui::Rect::from_min_max(egui::pos2(bounds.min.x, bounds.max.y - 1.0), bounds.max), + egui::Rect::from_min_max(bounds.min, egui::pos2(bounds.min.x + 1.0, bounds.max.y)), + egui::Rect::from_min_max(egui::pos2(bounds.max.x - 1.0, bounds.min.y), bounds.max), + ]; + for (child, clip) in &rects[at + 1..] { + if child.fill.a() != 0 { + let painted = child.rect.intersect(*clip); + assert!( + strips.iter().all(|edge| !edge.intersect(painted).is_positive()), + "{theme} {panel}: a later child background {painted:?} covers the focus outline {bounds:?}" + ); + } + } + } + } +} From f5a783769dd125697c970ab8dadd9ac7328b3820 Mon Sep 17 00:00:00 2001 From: Ikki Nishihara Date: Mon, 12 Oct 2026 05:17:19 +0900 Subject: [PATCH 244/260] =?UTF-8?q?fix(audio):=20=E3=82=B7=E3=83=A3?= =?UTF-8?q?=E3=83=88=E3=83=AB=E5=86=8D=E7=94=9F=E3=81=AE=E9=9F=B3=E7=A8=8B?= =?UTF-8?q?=E7=B6=AD=E6=8C=81=E8=A8=AD=E5=AE=9A=E3=82=92=E5=8F=8D=E6=98=A0?= =?UTF-8?q?=20(#959)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/ui-egui/src/lib.rs | 135 ++++++++++++++++-- crates/ui-egui/src/shuttle.rs | 257 ++++++++++++++++++++++++++++++++++ 2 files changed, 380 insertions(+), 12 deletions(-) create mode 100644 crates/ui-egui/src/shuttle.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 438112a29..a2db500af 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -49,6 +49,7 @@ pub mod perf; #[cfg(not(target_arch = "wasm32"))] pub mod play_ahead; pub mod scrub; +mod shuttle; pub mod source_playback; pub mod state; pub mod theme; @@ -683,21 +684,24 @@ impl FilmcraftApp { } self.frames.set_cache_budget(p.memory.frame_cache_mb as usize * (1 << 20)); self.ui.play_after_render = p.timeline.play_after_rendering; - if prev.as_ref().is_none_or(|q| q.audio_hardware != p.audio_hardware) { + let hardware_changed = prev.as_ref().is_none_or(|q| q.audio_hardware != p.audio_hardware); + let pitch_changed = prev.as_ref().is_some_and(|q| q.audio.maintain_pitch_shuttling != p.audio.maintain_pitch_shuttling); + if hardware_changed { if let Some(input) = self.session.voiceover.input.as_mut() { input.configure_host(&p.audio_hardware.device_class); } let rate = self.session.active_sequence().map(|q| q.settings.sample_rate); - let playing = self.playback.playing && self.playback.preroll.is_none(); if let Some(a) = self.audio.as_mut() { a.stop(); a.configure(&p.audio_hardware, rate); } - if playing { - self.playback.anchor_tick = self.session.playhead(); - self.playback.anchor_time = ctx.input(|i| i.time); - self.start_audio(); - } + } + if (hardware_changed || pitch_changed) && self.playback.playing && self.playback.preroll.is_none() { + self.playback.anchor_tick = self.session.playhead(); + let now = ctx.input(|i| i.time); + self.playback.anchor_time = now; + self.playback.audio_seen = (0, now); + self.start_audio(); } if let Some(note) = self.audio.as_ref().and_then(|a| a.note()) { self.ui.status = note; @@ -981,15 +985,13 @@ impl FilmcraftApp { let start_tick = self.session.playhead(); let Some(a) = self.audio.as_mut() else { return }; let sr = a.sample_rate(); - // 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 { + // Output frames still follow the accelerated timeline. The pitch preference selects + // either the original rate conversion or pitch-corrected program audio. + let Some((mix_rate, mix)) = shuttle::mix(&self.session, seq_id, sr, self.playback.speed) else { a.stop(); 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. @@ -2182,6 +2184,115 @@ mod audio_recovery_tests { app.stop(); } + /// Capture the block primed by the real shuttle playback path, without relying on a + /// physical device or consuming the producer faster than a device would. + #[test] + fn maintain_pitch_shuttling_controls_the_samples_sent_to_the_device() { + use std::sync::Mutex; + struct Capture(Arc>>); + impl AudioOut for Capture { + fn start(&mut self, mut fill: Box) -> Result { + let mut buf = vec![0.0; 2048]; + fill(&mut buf, 2); + *self.0.lock().unwrap() = buf; + Ok(48_000) + } + fn stop(&mut self) {} + fn sample_rate(&self) -> u32 { + 48_000 + } + fn played_frames(&self) -> Option { + Some(4_800) + } + } + let amplitude = |samples: &[f32], ch: usize, hz: f64| { + let (mut re, mut im) = (0.0, 0.0); + for (i, frame) in samples.as_chunks::<2>().0.iter().enumerate() { + let phase = std::f64::consts::TAU * hz * i as f64 / 48_000.0; + re += f64::from(frame[ch]) * phase.cos(); + im += f64::from(frame[ch]) * phase.sin(); + } + 2.0 * re.hypot(im) / (samples.len() / 2) as f64 + }; + let mut session = Session::default(); + session.execute("file.newSequence", json!({"width":16,"height":16})).unwrap(); + let inter: Vec = + (0..48_000 * 8).flat_map(|i| [440.0, 660.0].map(|hz| (0.2 * (std::f64::consts::TAU * hz * i as f64 / 48_000.0).sin()) as f32)).collect(); + let bytes: Arc<[u8]> = filmcraft_engine::previews::write_wav_f32(&inter, 48_000).into(); + let item = filmcraft_engine::commands::import_bytes(&mut session, "/shuttle-tone.wav", bytes, None).unwrap(); + session.execute("timeline.place", json!({"item":item.0,"audioTrack":"A1","seconds":0.0})).unwrap(); + let mut app = FilmcraftApp::new(session); + let samples = Arc::new(Mutex::new(Vec::new())); + app.audio = Some(Box::new(Capture(samples.clone()))); + let mut normal = None; + for maintain in [true, false] { + app.session.prefs.audio.maintain_pitch_shuttling = maintain; + for speed in [0.5, 1.0, 1.5, 2.0, 4.0] { + app.session.set_playhead(Tick::from_seconds_f64(1.0)); + let expected = app.session.sequence_rate().snap(app.session.playhead() + Tick::from_seconds_f64(0.1 * speed)); + app.play(speed); + app.end_preroll(0.0); + assert!(app.playback.audio_clock); + let buf = samples.lock().unwrap(); + for (ch, tone) in [440.0, 660.0].into_iter().enumerate() { + let hz = if maintain { tone } else { tone * speed }; + let level = amplitude(&buf, ch, hz); + assert!(level > 0.08, "maintain={maintain}, speed={speed}, channel={ch}: {hz} Hz level={level}"); + if speed != 1.0 { + let wrong = if maintain { tone * speed } else { tone }; + assert!(amplitude(&buf, ch, wrong) < level * 0.3, "wrong pitch {wrong} Hz at {speed}x, maintain={maintain}"); + } + } + if speed == 1.0 { + if let Some(previous) = &normal { + assert_eq!(&*buf, previous, "1x must bypass pitch processing"); + } else { + normal = Some(buf.clone()); + } + } + drop(buf); + app.advance_playback(&egui::Context::default()); + assert_eq!(app.session.playhead(), expected, "the device clock must advance the timeline at {speed}x, snapped to a video frame"); + app.stop(); + } + } + } + + #[test] + fn changing_pitch_preference_restarts_audio_at_the_current_playhead() { + let mut session = Session::default(); + session.execute("file.newSequence", json!({"width":16,"height":16})).unwrap(); + let ctx = egui::Context::default(); + 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.session.prefs.audio_hardware.default_output = "available".into(); + app.apply_prefs(&ctx); + app.play(2.0); + app.end_preroll(0.0); + assert_eq!(starts.load(Ordering::Relaxed), 1); + app.session.set_playhead(Tick::from_seconds_f64(2.0)); + let position = app.session.playhead(); + for (maintain, expected_starts) in [(false, 2), (true, 3)] { + app.session.execute("prefs.set", json!({"key":"audio.maintainPitchShuttling","value":maintain})).unwrap(); + app.apply_prefs(&ctx); + assert_eq!(starts.load(Ordering::Relaxed), expected_starts); + assert!(app.playback.audio_clock); + assert_eq!(app.playback.speed, 2.0); + assert_eq!(app.playback.anchor_tick, app.session.playhead()); + assert_eq!(app.session.playhead(), position); + app.apply_prefs(&ctx); + assert_eq!(starts.load(Ordering::Relaxed), expected_starts, "unchanged settings do not restart"); + } + for speed in [-1.0, 8.0] { + app.play(speed); + app.end_preroll(0.0); + assert!(!app.playback.audio_clock, "unsupported speed {speed} remains silent"); + } + assert_eq!(starts.load(Ordering::Relaxed), 3); + app.stop(); + } + #[test] fn changing_hardware_recovers_wall_clock_playback_without_rewinding() { let mut session = Session::default(); diff --git a/crates/ui-egui/src/shuttle.rs b/crates/ui-egui/src/shuttle.rs new file mode 100644 index 000000000..43ca47a61 --- /dev/null +++ b/crates/ui-egui/src/shuttle.rs @@ -0,0 +1,257 @@ +//! Pitch-preserving shuttle playback. Pitch is corrected before changing the sample rate, +//! so faster playback does not first discard the upper part of the audible spectrum. + +use filmcraft_audio_dsp::{AudioEffect, effects::PitchShifter, resample::StreamResampler}; +use filmcraft_engine::Session; +use filmcraft_project::ItemId; + +type Mixer = Box; +const BLOCK: usize = 1024; +const MAX_CHANNELS: usize = 64; +// Two shifters have at most 16384 frames of latency; leave room for priming and interpolation. +const FRAME_MARGIN: i128 = 65_536; + +pub(crate) fn mix(session: &Session, seq_id: ItemId, device_rate: u32, speed: f64) -> Option<(u32, Mixer)> { + let rate = super::shuttle_mix_rate(device_rate, speed)?; + if !session.prefs.audio.maintain_pitch_shuttling || rate == device_rate { + return Some((rate, Box::new(super::playback_mix(session, seq_id, rate)))); + } + // Slow playback needs headroom for the upward pitch correction before rate conversion. + let internal_rate = if rate > device_rate { device_rate.checked_mul(2)? } else { device_rate }; + let semitones = (12.0 * (f64::from(rate) / f64::from(device_rate)).log2()) as f32; + Some((rate, Box::new(corrected(super::playback_mix(session, seq_id, internal_rate), internal_rate, rate, semitones)))) +} + +fn corrected(mix: impl FnMut(i64, &mut [f32], usize) + Send + 'static, from: u32, to: u32, semitones: f32) -> impl FnMut(i64, &mut [f32], usize) + Send { + let mut source = PitchedStream { mix: Box::new(mix), rate: from, semitones, channels: 0, stages: Vec::new(), latency: 0, next: None }; + let mut resampler = StreamResampler::new(from, to); + move |cursor, buf: &mut [f32], ch| { + if ch == 0 || ch > MAX_CHANNELS || !buf.len().is_multiple_of(ch) { + buf.fill(0.0); + return; + } + let n = buf.len() / ch; + if n == 0 { + return; + } + // StreamResampler uses i64 source coordinates. Reject extremes before its interpolation + // and before the pitch stream's pre-roll/lookahead arithmetic can overflow. + let first = (i128::from(cursor) * i128::from(from)).div_euclid(i128::from(to.max(1))); + let last = ((i128::from(cursor) + n as i128) * i128::from(from)).div_euclid(i128::from(to.max(1))); + if first < i128::from(i64::MIN) + FRAME_MARGIN || last > i128::from(i64::MAX) - FRAME_MARGIN || i128::from(cursor) + n as i128 > i128::from(i64::MAX) { + buf.fill(0.0); + source.next = None; + resampler = StreamResampler::new(from, to); + return; + } + if source.channels != ch { + source.configure(ch); + resampler = StreamResampler::new(from, to); + } + for (b, chunk) in buf.chunks_mut(BLOCK * ch).enumerate() { + let start = cursor + (b * BLOCK) as i64; + let planar = resampler.process(start, chunk.len() / ch, |s, frames| source.pull(s, frames)); + for (i, frame) in chunk.chunks_exact_mut(ch).enumerate() { + for (sample, plane) in frame.iter_mut().zip(&planar) { + *sample = plane.get(i).copied().filter(|x| x.is_finite()).unwrap_or(0.0); + } + } + } + } +} + +struct PitchedStream { + mix: Mixer, + rate: u32, + semitones: f32, + channels: usize, + stages: Vec, + latency: usize, + // Expected next source output frame; mixer input is latency frames ahead of it. + next: Option, +} + +impl PitchedStream { + fn configure(&mut self, channels: usize) { + self.channels = channels; + self.stages.clear(); + let mut remaining = self.semitones; + // The existing effect supports ±12 semitones. Two stages cover 4x (-24 semitones). + for _ in 0..2 { + let shift = remaining.clamp(-12.0, 12.0); + let mut stage = PitchShifter::new(self.rate as f32, channels); + stage.set_param("semitones", shift); + stage.reset(); + self.stages.push(stage); + remaining -= shift; + if remaining.abs() < 1e-4 { + break; + } + } + self.latency = self.stages.iter().map(AudioEffect::latency).sum(); + self.next = None; + } + + fn process(&mut self, start: i64, n: usize) -> Vec> { + let mut interleaved = vec![0.0; n * self.channels]; + (self.mix)(start, &mut interleaved, self.channels); + let mut planar = vec![vec![0.0; n]; self.channels]; + for (i, frame) in interleaved.chunks_exact(self.channels).enumerate() { + for (plane, sample) in planar.iter_mut().zip(frame) { + plane[i] = if sample.is_finite() { *sample } else { 0.0 }; + } + } + let mut lanes: Vec<_> = planar.iter_mut().map(Vec::as_mut_slice).collect(); + for stage in &mut self.stages { + stage.process(&mut lanes); + } + planar + } + + fn pull(&mut self, start: i64, n: usize) -> Vec> { + if self.next != Some(start) { + for stage in &mut self.stages { + stage.reset(); + } + // Prime from earlier timeline audio and discard the delay. First returned audio is + // aligned with `start`, rather than shifting sound behind the accelerated picture. + let pre = self.latency * 2; + let Some(mut input) = start.checked_sub(pre as i64) else { return vec![vec![0.0; n]; self.channels] }; + let mut left = pre + self.latency; + while left > 0 { + let count = left.min(BLOCK); + self.process(input, count); + input += count as i64; + left -= count; + } + } + let mut result = vec![Vec::with_capacity(n); self.channels]; + let mut done = 0; + while done < n { + let count = (n - done).min(BLOCK); + let block = self.process(start + self.latency as i64 + done as i64, count); + for (out, plane) in result.iter_mut().zip(block) { + out.extend(plane); + } + done += count; + } + self.next = Some(start + n as i64); + result + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tone_mix(rate: u32, tones: Vec, burst: bool) -> impl FnMut(i64, &mut [f32], usize) + Send { + move |start, buf: &mut [f32], ch| { + for (i, frame) in buf.chunks_exact_mut(ch).enumerate() { + let time = (start + i as i64) as f64 / f64::from(rate); + for (sample, hz) in frame.iter_mut().zip(&tones) { + let on = !burst || (0.5..0.6).contains(&time); + *sample = if on { (0.3 * (std::f64::consts::TAU * hz * time).sin()) as f32 } else { 0.0 }; + } + } + } + } + + fn amplitude(samples: &[f32], hz: f64, sr: u32) -> f64 { + let (mut re, mut im) = (0.0, 0.0); + for (i, x) in samples.iter().enumerate() { + let phase = std::f64::consts::TAU * hz * i as f64 / f64::from(sr); + re += f64::from(*x) * phase.cos(); + im += f64::from(*x) * phase.sin(); + } + 2.0 * re.hypot(im) / samples.len() as f64 + } + + fn rates(sr: u32, speed: f64) -> (u32, u32, f32) { + let to = super::super::shuttle_mix_rate(sr, speed).unwrap(); + let from = if speed < 1.0 { sr * 2 } else { sr }; + (from, to, (12.0 * (f64::from(to) / f64::from(sr)).log2()) as f32) + } + + #[test] + fn original_tones_survive_shuttling_including_high_frequencies() { + for sr in [44_100, 48_000, 96_000] { + for speed in [0.5, 0.75, 1.5, 2.0, 4.0] { + let (from, to, semitones) = rates(sr, speed); + let tones = vec![440.0, 660.0, 6300.0, 9000.0, 18_000.0, 1000.0]; + let mut mix = corrected(tone_mix(from, tones.clone(), false), from, to, semitones); + let mut buf = vec![0.0; 16_000 * tones.len()]; + mix(i64::from(to), &mut buf, tones.len()); + for (ch, hz) in tones.iter().enumerate() { + let samples: Vec = buf.chunks_exact(tones.len()).map(|f| f[ch]).collect(); + let level = amplitude(&samples, *hz, sr); + assert!(level > 0.1, "{sr} Hz, {speed}x, channel {ch}: {hz} Hz level={level}"); + if hz * speed < f64::from(sr) / 2.0 { + assert!(amplitude(&samples, hz * speed, sr) < level * 0.15, "uncorrected pitch at {speed}x: {hz} Hz"); + } + } + } + } + } + + #[test] + fn block_boundaries_and_seeks_do_not_change_the_output() { + let (sr, speed) = (48_000, 4.0); + let (from, to, semitones) = rates(sr, speed); + let make = || corrected(tone_mix(from, vec![440.0, 660.0], false), from, to, semitones); + let mut whole_mix = make(); + let mut whole = vec![0.0; 24_000]; + whole_mix(i64::from(to), &mut whole, 2); + let mut cut_mix = make(); + let mut cut = Vec::new(); + let mut n = 0; + for size in [1, 7, 333, 1024].into_iter().cycle() { + let frames = size.min(12_000 - n); + let mut block = vec![0.0; frames * 2]; + cut_mix(i64::from(to) + n as i64, &mut block, 2); + cut.extend(block); + n += frames; + if n == 12_000 { + break; + } + } + assert_eq!(cut, whole); + let mut sought = vec![0.0; 4096]; + cut_mix(0, &mut sought, 2); + let mut fresh = vec![0.0; 4096]; + make()(0, &mut fresh, 2); + assert_eq!(sought, fresh, "a backward seek must reset the pitch history"); + } + + #[test] + fn pitch_latency_is_compensated_against_the_timeline() { + for speed in [0.5, 2.0, 4.0] { + let sr = 48_000; + let (from, to, semitones) = rates(sr, speed); + let mut mix = corrected(tone_mix(from, vec![440.0], true), from, to, semitones); + let mut buf = vec![0.0; (f64::from(sr) * 1.4) as usize]; + mix(0, &mut buf, 1); + let energy: f64 = buf.iter().map(|x| f64::from(*x).powi(2)).sum(); + let center: f64 = buf.iter().enumerate().map(|(i, x)| i as f64 / f64::from(sr) * f64::from(*x).powi(2)).sum::() / energy; + let expected = 0.55 / speed; + assert!(energy > 1.0); + assert!((center - expected).abs() < 0.025 / speed, "{speed}x: burst at {center}s, expected {expected}s"); + assert!(buf.iter().take((0.4 / speed * f64::from(sr)) as usize).all(|x| x.abs() < 1e-6), "audio must not arrive early"); + } + } + + #[test] + fn hostile_coordinates_and_channel_counts_produce_silence() { + let (from, to, semitones) = rates(48_000, 4.0); + let mut mix = corrected(tone_mix(from, vec![440.0, 660.0], false), from, to, semitones); + for (cursor, ch, len) in [(i64::MIN, 2, 2048), (i64::MAX, 2, 2048), (0, 0, 2048), (0, 65, 65), (0, 2, 3)] { + let mut buf = vec![1.0; len]; + mix(cursor, &mut buf, ch); + assert!(buf.iter().all(|x| *x == 0.0)); + } + mix(0, &mut [], 2); + let mut buf = vec![0.0; 2048]; + mix(i64::from(to), &mut buf, 2); + assert!(buf.iter().all(|x| x.is_finite())); + assert!(buf.iter().any(|x| x.abs() > 0.1)); + } +} From 4ebcf3aa67f514d977ae28716badf75c660f4d9c Mon Sep 17 00:00:00 2001 From: Lukas Keller Date: Sun, 11 Oct 2026 22:20:36 +0200 Subject: [PATCH 245/260] #1145: macOS app menu follows the interface language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit About FilmCraft and Join the ArtCraft Discord… in the macOS app menu take the translated labels of the same Help menu commands and are updated on a language switch. Hide, Hide Others and Quit get their text from the catalogs instead of muda's English defaults and keep their native actions. menus::NATIVE_APP_MENU lists these native-only labels together with the Settings submenu, so the app and the tests use the same keys. German rows for the three new keys; "Quit FilmCraft" is already translated in es, ja, zh-cn and fr (the app.quit shortcut), the other languages show English until they have translations. The German menu test accepts and requires the native app menu labels. Closes #1145 Co-Authored-By: Claude Opus 5.5 --- apps/filmcraft/src/native_menu.rs | 33 +++++++++++++++++++++++-------- crates/ui-egui/src/i18n/de.tsv | 5 ++++- crates/ui-egui/src/i18n/mod.rs | 7 ++++--- crates/ui-egui/src/menus.rs | 5 +++++ 4 files changed, 38 insertions(+), 12 deletions(-) diff --git a/apps/filmcraft/src/native_menu.rs b/apps/filmcraft/src/native_menu.rs index 61241a8a2..1abd3451a 100644 --- a/apps/filmcraft/src/native_menu.rs +++ b/apps/filmcraft/src/native_menu.rs @@ -4,7 +4,7 @@ use std::sync::mpsc::{Receiver, channel}; use filmcraft_ui_egui::FilmcraftApp; -use filmcraft_ui_egui::menus::{MENUS, MenuItem as Item, menu_items}; +use filmcraft_ui_egui::menus::{MENUS, MenuItem as Item, NATIVE_APP_MENU, menu_items}; use muda::accelerator::Accelerator; use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}; @@ -28,25 +28,39 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver, Sho let mut titles: Vec<(String, Submenu)> = Vec::new(); let bar = Menu::new(); let app_menu = Submenu::new("FilmCraft", true); + let [settings_label, hide_label, hide_others_label, quit_label] = NATIVE_APP_MENU; // FilmCraft ▸ Settings ▸ (Premiere's app-menu layout; General is Cmd+,) - let settings = Submenu::new(app.ui.language.tr("Settings"), true); - titles.push(("Settings".into(), settings.clone())); + let settings = Submenu::new(app.ui.language.tr(settings_label), true); + titles.push((settings_label.into(), settings.clone())); for it in items.iter().filter(|i| i.id.starts_with("app.settings.")) { let label = it.label.trim_end_matches('…').to_string(); let mi = MenuItem::with_id(it.id.clone(), label, true, it.shortcut.as_deref().and_then(accel)); native.push((it.id.clone(), mi.clone())); let _ = settings.append(&mi); } + // About and Discord repeat Help menu commands: same translated labels, updated with them + let label = |id: &str, english: &str| items.iter().find(|i| i.id == id).map_or_else(|| english.to_string(), |i| i.label.clone()); + let about = MenuItem::with_id("app.about", label("app.about", "About FilmCraft"), true, None); + let discord = MenuItem::with_id("help.discord", label("help.discord", "Join the ArtCraft Discord…"), true, None); + native.push(("app.about".into(), about.clone())); + native.push(("help.discord".into(), discord.clone())); + // the predefined items keep their native actions; our text lets them follow the language + let predefined = [ + (hide_label, PredefinedMenuItem::hide(Some(app.ui.language.tr(hide_label)))), + (hide_others_label, PredefinedMenuItem::hide_others(Some(app.ui.language.tr(hide_others_label)))), + (quit_label, PredefinedMenuItem::quit(Some(app.ui.language.tr(quit_label)))), + ]; + let [(_, hide), (_, hide_others), (_, quit)] = &predefined; let _ = app_menu.append_items(&[ - &MenuItem::with_id("app.about", "About FilmCraft", true, None), - &MenuItem::with_id("help.discord", "Join the ArtCraft Discord…", true, None), + &about, + &discord, &PredefinedMenuItem::separator(), &settings, &PredefinedMenuItem::separator(), - &PredefinedMenuItem::hide(None), - &PredefinedMenuItem::hide_others(None), + hide, + hide_others, &PredefinedMenuItem::separator(), - &PredefinedMenuItem::quit(None), + quit, ]); let _ = bar.append(&app_menu); for top in MENUS { @@ -115,6 +129,9 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver, Sho for (title, submenu) in &titles { submenu.set_text(language.tr(title)); } + for (label, item) in &predefined { + item.set_text(language.tr(label)); + } for (id, mi) in &native { if let Some(it) = items.iter().find(|i| &i.id == id) { mi.set_text(&it.label); diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 51a33dd5d..11c51af7c 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -6,7 +6,7 @@ # 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. +# app 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 = @@ -180,6 +180,8 @@ Guide Templates Hilfslinienvorlagen Help Hilfe Hide All Caption Tracks Alle Untertitelspuren ausblenden + Hide FilmCraft FilmCraft ausblenden + Hide Others Andere ausblenden High Quality Playback Wiedergabe in hoher Qualität History Verlauf Import From Importieren aus @@ -269,6 +271,7 @@ Properties Eigenschaften Proxy Proxy Purple Lila + Quit FilmCraft FilmCraft beenden Reconnect Full Resolution Media… Medien in voller Auflösung neu verknüpfen… Recover Unsaved Changes… Nicht gespeicherte Änderungen wiederherstellen… Rectangle Rechteck diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 7225b5c98..574e9442f 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -931,7 +931,7 @@ 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. + /// and the native macOS app 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()); @@ -939,17 +939,18 @@ mod tests { 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}"); + assert!(known(&en) || crate::menus::NATIVE_APP_MENU.contains(&en.as_str()), "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"]) { + for en in crate::menus::MENUS.into_iter().chain(["Audio"]).chain(crate::menus::NATIVE_APP_MENU) { assert!(Language::De.has(en), "untranslated German menu: {en}"); } assert_eq!(Language::De.tr("Settings"), "Voreinstellungen"); + assert_eq!(Language::De.tr("Quit FilmCraft"), "FilmCraft beenden"); } /// French covers the whole interface (the Spanish catalog's entries and the native macOS diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index f48711505..ceae2234b 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -622,6 +622,11 @@ pub struct MenuItem { pub const MENUS: [&str; 9] = ["File", "Edit", "Clip", "Sequence", "Markers", "Graphics and Titles", "View", "Window", "Help"]; +/// Labels only the native macOS app menu shows, with no command in the menu tree behind them: the +/// Settings submenu and the predefined Hide, Hide Others and Quit items. They are translated through +/// the catalogs like menu labels (`apps/filmcraft/src/native_menu.rs`). +pub const NATIVE_APP_MENU: [&str; 4] = ["Settings", "Hide FilmCraft", "Hide Others", "Quit FilmCraft"]; + pub fn menu_items(app: &FilmcraftApp) -> Vec { let mut v = menu_items_for(&app.session); for it in &mut v { From 4763b553d984e96fa99fb3e102971ec9fabe61f5 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:36:37 +0200 Subject: [PATCH 246/260] Hot-text number fields select the whole value when clicked (#1126) --- crates/ui-egui/src/widgets.rs | 53 +++++++++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index d57b01ebe..c2230d697 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -10,9 +10,11 @@ use crate::theme::Tokens; /// Returns (response, Some(new value) when changed). pub fn hot_number(ui: &mut Ui, id: egui::Id, value: f64, speed: f64, range: (f64, f64), decimals: usize, suffix: &str, t: &Tokens) -> (Response, Option) { let editing_id = id.with("editing"); + let edit_id = id.with("edit"); + let select_all_id = id.with("select-all"); let mut editing: Option = ui.data(|d| d.get_temp(editing_id)); if let Some(buf) = editing.as_mut() { - let r = ui.add(egui::TextEdit::singleline(buf).desired_width(56.0).font(Tokens::ui(12.0))); + let r = ui.add(egui::TextEdit::singleline(buf).id(edit_id).desired_width(56.0).font(Tokens::ui(12.0))); let mut out = None; if r.lost_focus() { if let Ok(v) = buf.trim().trim_end_matches(suffix).trim().parse::() { @@ -22,6 +24,18 @@ pub fn hot_number(ui: &mut Ui, id: egui::Id, value: f64, speed: f64, range: (f64 } else { let b = buf.clone(); ui.data_mut(|d| d.insert_temp(editing_id, b)); + // select the whole value once the field has focus (egui collapses a selection to a caret + // in a field that does not have it yet), so typing replaces it as in Premiere + let selected_now = r.has_focus() && ui.data_mut(|d| d.remove_temp::(select_all_id)).is_some(); + if selected_now { + let mut st = egui::text_edit::TextEditState::load(ui.ctx(), edit_id).unwrap_or_default(); + st.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(buf.chars().count())))); + st.store(ui.ctx(), edit_id); + } + // the focus and the selection show on the next frame, which nothing else would trigger + if !r.has_focus() || selected_now { + ui.ctx().request_repaint(); + } r.request_focus(); } return (r, out); @@ -54,7 +68,11 @@ pub fn hot_number(ui: &mut Ui, id: egui::Id, value: f64, speed: f64, range: (f64 } } if resp.clicked() { - ui.data_mut(|d| d.insert_temp(editing_id, format!("{value:.decimals$}"))); + ui.data_mut(|d| { + d.insert_temp(editing_id, format!("{value:.decimals$}")); + d.insert_temp(select_all_id, true); + }); + ui.ctx().request_repaint(); } (resp, out) } @@ -278,3 +296,34 @@ pub fn meter_bar(painter: &egui::Painter, rect: Rect, db: f32, peak_db: f32, t: Stroke::new(1.0, if peak_db > -0.5 { t.danger } else { Color32::from_rgb(0xf0, 0xf0, 0x4f) }), ); } + +#[cfg(test)] +mod tests { + use super::*; + use crate::theme::ThemeKind; + + fn frame(ctx: &egui::Context, events: Vec, id: egui::Id) { + let t = Tokens::for_kind(ThemeKind::Dark); + let input = egui::RawInput { events, ..Default::default() }; + let _ = ctx.run_ui(input, |ui| { + hot_number(ui, id, 123.5, 1.0, (0.0, 1000.0), 1, "", &t); + }); + } + + #[test] + fn clicking_a_hot_number_selects_its_whole_value() { + let ctx = egui::Context::default(); + let id = egui::Id::new("hot"); + let pos = egui::pos2(14.0, 12.0); + let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + frame(&ctx, vec![egui::Event::PointerMoved(pos)], id); + frame(&ctx, vec![button(true)], id); + frame(&ctx, vec![button(false)], id); + for _ in 0..4 { + frame(&ctx, vec![], id); + } + let st = egui::text_edit::TextEditState::load(&ctx, id.with("edit")).unwrap_or_default(); + let range = st.cursor.char_range().map(|c| c.sorted_cursors()); + assert_eq!(range, Some([egui::text::CCursor::new(0), egui::text::CCursor::new(5)])); + } +} From 92eb83695771905ccee0e8c8e6996f53713ad726 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:37:45 +0200 Subject: [PATCH 247/260] Timeline: dragging a clip above the top track or below the last one adds a new track (#1104) --- crates/engine/src/commands.rs | 9 +++- crates/engine/src/sequence_tools.rs | 59 +++++++++++++++++++++++ crates/engine/src/sequence_tools_tests.rs | 23 +++++++++ crates/ui-egui/src/panels/timeline.rs | 25 ++++++++-- 4 files changed, 110 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 6609afddf..73b42b535 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -2279,9 +2279,14 @@ fn build() -> Vec { "Move Clips", [], None, - r#"{"moves":[{"clip":id,"track":id|"V2","time":ticks}],"insert":bool,"linked":bool?,"copy":bool?}"#, + r#"{"moves":[{"clip":id,"track":id|"V2"|"new" (after the last of its kind),"time":ticks}],"insert":bool,"linked":bool?,"copy":bool?}"#, has_seq, - move_clips + |s, p| { + if crate::sequence_tools::move_wants_new_track(p) { + return crate::sequence_tools::move_to_new_tracks(s, p); + } + move_clips(s, p) + } ), cmd!( "timeline.trim", diff --git a/crates/engine/src/sequence_tools.rs b/crates/engine/src/sequence_tools.rs index dc6c57953..ddee169d2 100644 --- a/crates/engine/src/sequence_tools.rs +++ b/crates/engine/src/sequence_tools.rs @@ -771,6 +771,65 @@ pub(crate) fn place_on_new_tracks(s: &mut Session, p: &Value) -> Result { } } +/// `timeline.move` names `"new"` as the `track` of a move. +pub(crate) fn move_wants_new_track(p: &Value) -> bool { + p.get("moves").and_then(Value::as_array).is_some_and(|m| m.iter().any(is_new_move)) +} + +/// One move of `timeline.move` with `"track":"new"`. +fn is_new_move(m: &Value) -> bool { + m.get("track").and_then(Value::as_str).is_some_and(|v| v.eq_ignore_ascii_case("new")) +} + +/// `timeline.move` with `"track":"new"` on some moves (#1104: a clip dragged into the empty space +/// above the top video track or below the last audio track): one track is added after the last one +/// of each kind the `"new"` clips come from, they all land on it, and the track and the move are +/// one undo step. When the move fails, the new tracks go away again. +pub(crate) fn move_to_new_tracks(s: &mut Session, p: &Value) -> Result { + let cmd = "timeline.move"; + let q = s.active_sequence().ok_or(EngineError::NoSequence)?; + let (mut new_v, mut new_a) = (false, false); + for m in p.get("moves").and_then(Value::as_array).into_iter().flatten().filter(|m| is_new_move(m)) { + let clip = crate::commands::clip_p(m, "clip").ok_or_else(|| bad(cmd, "need `clip` in each move"))?; + let (track, _) = q.find_item(clip).ok_or_else(|| bad(cmd, "no such clip"))?; + if q.video_tracks.iter().any(|t| t.id == track) { + new_v = true; + } else { + new_a = true; + } + } + let added = add_tracks(s, &json!({"video": u64::from(new_v), "audio": u64::from(new_a)}))?; + let mark = s.history.undo.len(); + let (video, audio) = (added.get("video").and_then(|v| v.get(0)).cloned(), added.get("audio").and_then(|v| v.get(0)).cloned()); + let mut moves = p.get("moves").and_then(Value::as_array).cloned().unwrap_or_default(); + for m in moves.iter_mut().filter(|m| is_new_move(m)) { + let on_video = crate::commands::clip_p(m, "clip") + .and_then(|c| s.active_sequence().and_then(|q| q.find_item(c).map(|(t, _)| q.video_tracks.iter().any(|v| v.id == t)))) + .unwrap_or(true); + m["track"] = if on_video { video.clone() } else { audio.clone() }.unwrap_or(Value::Null); + } + let mut q = p.clone(); + q["moves"] = Value::Array(moves); + let run = crate::commands::find(cmd).ok_or_else(|| EngineError::UnknownCommand(cmd.into()))?.run; + match run(s, &q) { + Ok(v) => { + // the new tracks and the move 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 d275f9b3e..593a7a150 100644 --- a/crates/engine/src/sequence_tools_tests.rs +++ b/crates/engine/src/sequence_tools_tests.rs @@ -607,3 +607,26 @@ fn place_on_new_tracks_adds_them_in_one_undo_step() { assert_eq!(*s.active_sequence().unwrap(), before); assert_eq!(s.history.undo.len(), undo); } + +/// #1104: a clip dragged into the empty space past the last track moves onto a new track (with +/// `"new"`), one undo step; a move that fails leaves no new track behind. +#[test] +fn move_to_new_track_adds_it_in_one_undo_step() { + let (mut s, item) = fresh(); + let clips = place(&mut s, item, "V1", 0, 0, 10); + let before = s.active_sequence().unwrap().clone(); + let nv = before.video_tracks.len(); + let undo = s.history.undo.len(); + s.execute("timeline.move", json!({"moves": [{"clip": clips[0], "track": "new", "time": 0}], "linked": false})).unwrap(); + let q = s.active_sequence().unwrap(); + assert_eq!(q.video_tracks.len(), nv + 1); + assert_eq!(q.video_tracks[nv].items.iter().map(|i| i.id.0).collect::>(), [clips[0]], "the clip is on the new top video track"); + assert!(q.video_tracks[0].items.is_empty()); + assert_eq!(s.history.undo.len(), undo + 1, "one undo step"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(*s.active_sequence().unwrap(), before); + + assert!(s.execute("timeline.move", json!({"moves": [{"clip": 999_999, "track": "new", "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 d5766c0bb..5f933e0e3 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -94,6 +94,9 @@ pub enum Drag { start_track: TrackId, offset: Tick, track_delta: i32, + /// #1104: the pointer is in the empty space past the last track of the dragged clips' kind: + /// the drop moves them onto a new track there. + new_track: Option<(TrackKind, Rect)>, /// 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, @@ -2137,7 +2140,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, copy: mods.alt }) + Some(Drag::Move { clips, grab_tick: t, start_track: track, offset: Tick::ZERO, track_delta: 0, new_track: None, copy: mods.alt }) } else { None } @@ -2238,7 +2241,19 @@ 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, copy: mods.alt }) + // #1104: past the last track of the clips' kind, a new track (only for clips of one track) + let new_track = start_row + .filter(|_| clips.iter().all(|c| seq.find_item(*c).is_some_and(|(t, _)| t == start_track))) + .and_then(|s| { + let (vh, ah) = (app.ui.timeline.video_track_h, app.ui.timeline.audio_track_h); + new_track_at(seq, layout, p.y, vh, ah).filter(|(kind, _)| *kind == s.kind) + }); + if let Some((_, band)) = new_track { + let (x0, x1) = (layout.x_of((first + offset).max(Tick::ZERO)), layout.x_of((last + offset).max(Tick::ZERO))); + let r = Rect::from_min_max(pos2(x0, band.min.y + 1.0), pos2(x1, band.max.y - 1.0)); + ui.painter().rect_stroke(r, 3.0, Stroke::new(1.5, Color32::WHITE), StrokeKind::Inside); + } + Some(Drag::Move { clips, grab_tick, start_track, offset, track_delta, new_track, 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() }; @@ -2305,10 +2320,12 @@ 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, copy, .. } if offset != Tick::ZERO || track_delta != 0 => { + Drag::Move { clips, offset, track_delta, new_track, copy, .. } if offset != Tick::ZERO || track_delta != 0 || new_track.is_some() => { + // #1104: dropped past the last track, the clips go onto a new one + let to = |tid| new_track.map_or_else(|| json!(shift_track(seq, tid, track_delta).unwrap_or(tid).0), |_| json!("new")); 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}))) + .filter_map(|c| seq.find_item(*c).map(|(tid, it)| json!({"clip": c.0, "track": to(tid), "time": (it.start + offset).max(Tick::ZERO).0}))) .collect(); // 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}))) From 94a61d824f9a96e631208e83c20dcc79dabe1b91 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 18:25:38 -0400 Subject: [PATCH 248/260] render: fix clippy and rustfmt in the noise cadence test Co-Authored-By: Claude Opus 5.5 --- crates/render/tests/noise_frame_cadence.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/render/tests/noise_frame_cadence.rs b/crates/render/tests/noise_frame_cadence.rs index 57a6a7e18..b36a6f399 100644 --- a/crates/render/tests/noise_frame_cadence.rs +++ b/crates/render/tests/noise_frame_cadence.rs @@ -1,6 +1,9 @@ use filmcraft_project::{ParamValue, find_effect}; -use filmcraft_render::{Image, effects::{FxCtx, apply}}; -use filmcraft_time::{FrameRate, Tick}; +use filmcraft_render::{ + Image, + effects::{FxCtx, apply}, +}; +use filmcraft_time::FrameRate; fn render_noise(effect_id: &str, rate: FrameRate, frame: i64, amount: f64) -> Vec { let mut image = Image::filled(16, 16, [0.5, 0.5, 0.5, 1.0]); @@ -34,6 +37,6 @@ fn both_noise_variants_change_on_every_sequence_frame() { } // Floating-point second-to-30Hz conversion also repeated frames 122/123 at 30 fps. assert_ne!(render_noise(effect_id, FrameRate::FPS_30, 122, 100.0), render_noise(effect_id, FrameRate::FPS_30, 123, 100.0)); - assert_eq!(render_noise(effect_id, FrameRate::FPS_60, 0, 0.0), vec![0.5, 0.5, 0.5, 1.0].repeat(16 * 16)); + assert_eq!(render_noise(effect_id, FrameRate::FPS_60, 0, 0.0), [0.5, 0.5, 0.5, 1.0].repeat(16 * 16)); } } From 2ac8237318fa2bd17e02ba9dc56aebd419588016 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 19:06:42 -0400 Subject: [PATCH 249/260] text: rustfmt the underline shift test Co-Authored-By: Claude Opus 5.5 --- crates/text/tests/underline_shift.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/text/tests/underline_shift.rs b/crates/text/tests/underline_shift.rs index b8d118f74..8cb5e4e33 100644 --- a/crates/text/tests/underline_shift.rs +++ b/crates/text/tests/underline_shift.rs @@ -1,5 +1,5 @@ //! Whole-style underline must follow `baseline_shift` the same way character-style runs do. -use filmcraft_text::{layout, layout_rich, ParagraphStyle, StyleRun, TextStyle}; +use filmcraft_text::{ParagraphStyle, StyleRun, TextStyle, layout, layout_rich}; fn style(shift: f32) -> TextStyle { TextStyle { size: 100.0, underline: true, baseline_shift: shift, ..Default::default() } From 039dd061101eab29bc6c36e778c3776133e56c80 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 19:12:10 -0400 Subject: [PATCH 250/260] render: as_chunks in the Extract edge test (clippy::chunks_exact_with_const_size) Co-Authored-By: Claude Opus 5.5 --- crates/render/src/gpufx.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/render/src/gpufx.rs b/crates/render/src/gpufx.rs index 00b827fd6..6876a6d9e 100644 --- a/crates/render/src/gpufx.rs +++ b/crates/render/src/gpufx.rs @@ -1139,9 +1139,9 @@ mod tests { .collect(), }; FxOp::eval(&e, &cx, 4, 1).unwrap().apply(&mut img); - for (pixel, inside) in img.px.chunks_exact(4).zip([false, true, true, false]) { + for (pixel, inside) in img.px.as_chunks::<4>().0.iter().zip([false, true, true, false]) { let value = if inside != invert { alpha } else { 0.0 }; - assert_eq!(pixel, [value, value, value, alpha], "softness={softness}, invert={invert}"); + assert_eq!(*pixel, [value, value, value, alpha], "softness={softness}, invert={invert}"); } } } From 3494df4c90dd2a9a0243d3e76c2e7a79b03ee6c0 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 19:12:17 -0400 Subject: [PATCH 251/260] engine: rustfmt project tools tests Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/project_tools_tests.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/project_tools_tests.rs b/crates/engine/src/project_tools_tests.rs index fd5ca4041..03caa0696 100644 --- a/crates/engine/src/project_tools_tests.rs +++ b/crates/engine/src/project_tools_tests.rs @@ -502,7 +502,9 @@ fn deleting_media_used_on_the_timeline_makes_it_offline() { let mut s = Session::default(); s.execute("file.newProject", json!({"name": "Offline"})).unwrap(); s.execute("file.newSequence", json!({"name": "Seq", "fps": 25, "video": 1, "audio": 0, "width": 16, "height": 16})).unwrap(); - let item = s.execute("file.newOfflineFile", json!({"name": "Clip", "seconds": 2, "fps": 25, "video": true, "audio": false, "width": 16, "height": 16})).unwrap()["item"] + let item = s + .execute("file.newOfflineFile", json!({"name": "Clip", "seconds": 2, "fps": 25, "video": true, "audio": false, "width": 16, "height": 16})) + .unwrap()["item"] .as_u64() .unwrap(); s.execute("timeline.place", json!({"item": item, "track": "V1", "time": 0})).unwrap(); @@ -512,7 +514,9 @@ fn deleting_media_used_on_the_timeline_makes_it_offline() { assert_eq!(r["items"], 0); assert!(s.project.item(ItemId(item)).unwrap().as_media().unwrap().offline); assert_eq!(s.project.sequences().next().unwrap().as_sequence().unwrap().video_tracks[0].items.len(), clips_before); - let unused = s.execute("file.newOfflineFile", json!({"name": "Spare", "seconds": 1, "fps": 25, "video": true, "audio": false, "width": 16, "height": 16})).unwrap()["item"] + let unused = s + .execute("file.newOfflineFile", json!({"name": "Spare", "seconds": 1, "fps": 25, "video": true, "audio": false, "width": 16, "height": 16})) + .unwrap()["item"] .as_u64() .unwrap(); let r = s.execute("project.delete", json!({"items": [unused]})).unwrap(); From 0289cec1a05b0fcd1c5fd468705da9472948e888 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 19:15:44 -0400 Subject: [PATCH 252/260] gpu: as_chunks in the Extract tests (clippy::chunks_exact_with_const_size) Co-Authored-By: Claude Opus 5.5 --- crates/gpu/src/fx_tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/gpu/src/fx_tests.rs b/crates/gpu/src/fx_tests.rs index 31fc55a5a..37603bca2 100644 --- a/crates/gpu/src/fx_tests.rs +++ b/crates/gpu/src/fx_tests.rs @@ -350,7 +350,7 @@ fn gpu_extract_zero_softness_has_no_fractional_edge() { let e = effect("extract", &[("black", fl(90.0)), ("white", fl(160.0)), ("invert", ParamValue::Bool(invert))]); let op = FxOp::eval(&e, &cx(Tick::ZERO, 1.0), w as usize, h as usize).unwrap(); let (_, _, gpu) = c.effect_image(&frame, &LayerFx { size: (w, h), decimation: 1, ops: vec![op] }).unwrap(); - for (src, out) in px.chunks_exact(4).zip(gpu.chunks_exact(4)) { + for (src, out) in px.as_chunks::<4>().0.iter().zip(gpu.as_chunks::<4>().0.iter()) { assert_eq!(out[3], src[3], "Extract must preserve alpha"); assert!(out[0] == 0.0 || out[0] == out[3], "zero softness produced a fractional edge: {out:?}"); assert_eq!(out[0], out[1]); @@ -358,7 +358,7 @@ fn gpu_extract_zero_softness_has_no_fractional_edge() { } masks.push(gpu); } - for ((normal, inverted), src) in masks[0].chunks_exact(4).zip(masks[1].chunks_exact(4)).zip(px.chunks_exact(4)) { + for ((normal, inverted), src) in masks[0].as_chunks::<4>().0.iter().zip(masks[1].as_chunks::<4>().0.iter()).zip(px.as_chunks::<4>().0.iter()) { assert_eq!(normal[0] + inverted[0], src[3], "inversion must complement the premultiplied mask"); } } From e923025d299b2b539e0edccb61089c314013e1cf Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 19:33:04 -0400 Subject: [PATCH 253/260] Merge main into #1167; the new marquee-drag Move starts with no new track Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/timeline.rs | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index aa0f839af..122867856 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2217,7 +2217,15 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & // as in Premiere Pro, a drag with the tool moves what it just selected if resp.drag_started() { let clips = app.session.state.selection.clone(); - (!clips.is_empty()).then_some(Drag::Move { clips, grab_tick: t, start_track: track, offset: Tick::ZERO, track_delta: 0, copy: mods.alt }) + (!clips.is_empty()).then_some(Drag::Move { + clips, + grab_tick: t, + start_track: track, + offset: Tick::ZERO, + track_delta: 0, + new_track: None, + copy: mods.alt, + }) } else { None } @@ -2361,12 +2369,10 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & _ => 0, }; // #1104: past the last track of the clips' kind, a new track (only for clips of one track) - let new_track = start_row - .filter(|_| clips.iter().all(|c| seq.find_item(*c).is_some_and(|(t, _)| t == start_track))) - .and_then(|s| { - let (vh, ah) = (app.ui.timeline.video_track_h, app.ui.timeline.audio_track_h); - new_track_at(seq, layout, p.y, vh, ah).filter(|(kind, _)| *kind == s.kind) - }); + let new_track = start_row.filter(|_| clips.iter().all(|c| seq.find_item(*c).is_some_and(|(t, _)| t == start_track))).and_then(|s| { + let (vh, ah) = (app.ui.timeline.video_track_h, app.ui.timeline.audio_track_h); + new_track_at(seq, layout, p.y, vh, ah).filter(|(kind, _)| *kind == s.kind) + }); if let Some((_, band)) = new_track { let (x0, x1) = (layout.x_of((first + offset).max(Tick::ZERO)), layout.x_of((last + offset).max(Tick::ZERO))); let r = Rect::from_min_max(pos2(x0, band.min.y + 1.0), pos2(x1, band.max.y - 1.0)); From 4a09d92fa5f4d64629421f1593e7fdeb47cc63ae Mon Sep 17 00:00:00 2001 From: ReReddo <249398616+ReReddo@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:49:58 +0300 Subject: [PATCH 254/260] labels: label color presets in Settings > Labels A Label Color Presets row under Label Defaults picks the 16 label names and colours from a preset. The dropdown lists the built-in "Default" (the labels FilmCraft ships with) and the presets in the Label Presets folder beside the preferences, and reads " (Modified)" once the labels differ from the chosen preset. Icon buttons: Revert (back to the preset's colours and names), Import (copies a preset file into the folder and selects it), Export (saves the dialog's labels to a file named as the preset), Show Folder, and Delete (user presets only). Choosing a preset or reverting applies on OK like the other fields; Cancel discards it. Presets are small JSON files: a name and 16 labels, each a name and a labels, names trimmed and at most 64 characters, valid colours); a bad file shows its error in the dialog and changes nothing. The same is reachable as labels.presets, labels.importPreset, labels.exportPreset and labels.deletePreset, with the selection stored as labels.preset. --- crates/engine/src/settings.rs | 198 +++++++++++++++++++++++++- crates/engine/src/settings_tests.rs | 91 ++++++++++++ crates/ui-egui/src/i18n/es.tsv | 7 + crates/ui-egui/src/i18n/fr.tsv | 7 + crates/ui-egui/src/i18n/ja.tsv | 7 + crates/ui-egui/src/i18n/zh-cn.tsv | 7 + crates/ui-egui/src/icons.rs | 8 ++ crates/ui-egui/src/panels/settings.rs | 111 +++++++++++++-- crates/ui-egui/tests/settings_ui.rs | 103 ++++++++++++++ docs/project-files.md | 2 +- 10 files changed, 528 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/settings.rs b/crates/engine/src/settings.rs index 00d83f442..8f92c7b1a 100644 --- a/crates/engine/src/settings.rs +++ b/crates/engine/src/settings.rs @@ -326,11 +326,17 @@ impl Default for LabelDefaults { pub struct LabelPrefs { pub colors: BTreeMap, pub defaults: LabelDefaults, + /// Label Color Presets: the preset the names and colours were last taken from. + pub preset: String, } impl Default for LabelPrefs { fn default() -> Self { - Self { colors: Label::ALL.iter().map(|l| (label_id(*l), default_label_color(*l))).collect(), defaults: LabelDefaults::default() } + Self { + colors: Label::ALL.iter().map(|l| (label_id(*l), default_label_color(*l))).collect(), + defaults: LabelDefaults::default(), + preset: DEFAULT_LABEL_PRESET.into(), + } } } @@ -386,6 +392,164 @@ impl LabelPrefs { c.name = c.name.chars().take(64).collect(); } self.colors.retain(|k, _| Label::ALL.iter().any(|l| label_id(*l) == *k)); + self.preset = self.preset.chars().take(LABEL_NAME_MAX).collect(); + } +} + +/// The label colour preset FilmCraft ships with: the labels' own names and colours. +pub const DEFAULT_LABEL_PRESET: &str = "Default"; +/// The folder of label colour presets, beside `preferences.json`. +pub const LABEL_PRESETS_DIR: &str = "Label Presets"; +/// Largest label preset file read; a real one is under 2 KiB. +const LABEL_PRESET_MAX_BYTES: u64 = 64 * 1024; +/// Longest preset or label name, in characters. +const LABEL_NAME_MAX: usize = 64; +/// Most files looked at in the presets folder. +const LABEL_PRESETS_MAX: usize = 500; + +/// A label colour preset (Settings ▸ Labels ▸ Label Color Presets), shared between editors as a +/// small JSON file: its name and the 16 labels' names and colours, in [`Label::ALL`] order. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct LabelPreset { + pub name: String, + pub labels: Vec, +} + +/// One label of a [`LabelPreset`]. A file must give both fields. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct PresetLabel { + pub name: String, + /// `#rrggbb`. + pub color: String, +} + +impl LabelPreset { + /// The built-in preset ([`DEFAULT_LABEL_PRESET`]). + pub fn builtin() -> Self { + Self::from_prefs(DEFAULT_LABEL_PRESET, &LabelPrefs::default()) + } + + /// `labels`' names and colours as a preset called `name`. + pub fn from_prefs(name: &str, labels: &LabelPrefs) -> Self { + let entry = |l: Label| labels.colors.get(&label_id(l)).cloned().unwrap_or_else(|| default_label_color(l)); + Self { name: name.into(), labels: Label::ALL.iter().map(|l| entry(*l)).map(|c| PresetLabel { name: c.name, color: c.color }).collect() } + } + + /// Whether `labels` has exactly this preset's names and colours. + pub fn matches(&self, labels: &LabelPrefs) -> bool { + Self::from_prefs(&self.name, labels) == *self + } + + /// Give `labels` this preset's names and colours (the label defaults stay) and select it. + pub fn apply(&self, labels: &mut LabelPrefs) { + for (l, p) in Label::ALL.iter().zip(&self.labels) { + labels.colors.insert(label_id(*l), LabelColor { name: p.name.clone(), color: p.color.clone() }); + } + labels.preset = self.name.clone(); + } + + /// Check a preset file: at most 64 KiB of JSON with a name and exactly 16 labels, each with a + /// name (trimmed, at most 64 characters) and a `#rrggbb` colour. + pub fn parse(bytes: &[u8]) -> std::result::Result { + if bytes.len() as u64 > LABEL_PRESET_MAX_BYTES { + return Err("the file is too large to be a label color preset".into()); + } + let mut p: Self = serde_json::from_slice(bytes).map_err(|e| format!("not a label color preset: {e}"))?; + p.name = p.name.trim().to_string(); + if p.name.is_empty() || p.name.chars().count() > LABEL_NAME_MAX { + return Err(format!("the preset needs a name of 1 to {LABEL_NAME_MAX} characters")); + } + if p.labels.len() != Label::ALL.len() { + return Err(format!("a label color preset has {} labels; this file has {}", Label::ALL.len(), p.labels.len())); + } + for (i, l) in p.labels.iter_mut().enumerate() { + l.name = l.name.trim().to_string(); + if l.name.chars().count() > LABEL_NAME_MAX { + return Err(format!("label {}: the name is longer than {LABEL_NAME_MAX} characters", i + 1)); + } + let rgb = l.color.strip_prefix('#').and(parse_hex(&l.color)).ok_or_else(|| format!("label {}: the color is not like #2f6bdf", i + 1))?; + l.color = hex(rgb); + } + Ok(p) + } + + /// Read and check a preset file ([`LabelPreset::parse`]). + pub fn read(path: &Path) -> std::result::Result { + use std::io::Read as _; + let file = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?; + let mut bytes = Vec::new(); + file.take(LABEL_PRESET_MAX_BYTES + 1).read_to_end(&mut bytes).map_err(|e| format!("{}: {e}", path.display()))?; + Self::parse(&bytes) + } + + pub fn write(&self, path: &Path) -> std::result::Result<(), String> { + let bytes = serde_json::to_vec_pretty(self).map_err(|e| e.to_string())?; + filmcraft_format::atomic_write(path, &bytes).map_err(|e| format!("{}: {e}", path.display())) + } + + /// Save `labels`' names and colours to `path` as a preset named after the file. + pub fn export(path: &Path, labels: &LabelPrefs) -> std::result::Result { + let stem = path.file_stem().map(|s| s.to_string_lossy().trim().chars().take(LABEL_NAME_MAX).collect::()).unwrap_or_default(); + let p = Self::from_prefs(if stem.is_empty() { "Labels" } else { &stem }, labels); + p.write(path)?; + Ok(p) + } +} + +impl Session { + /// The label colour presets folder (None without a data directory, e.g. tests and the web). + pub fn label_presets_dir(&self) -> Option { + self.prefs_path.as_deref().and_then(Path::parent).map(|d| d.join(LABEL_PRESETS_DIR)) + } + + /// The built-in preset, then the presets in the folder by name with the file each is in. + /// Files that fail [`LabelPreset::parse`] and names already taken are left out. + pub fn label_presets(&self) -> Vec<(LabelPreset, Option)> { + let mut found: Vec<(LabelPreset, Option)> = self + .label_presets_dir() + .and_then(|d| std::fs::read_dir(d).ok()) + .into_iter() + .flatten() + .flatten() + .take(LABEL_PRESETS_MAX) + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|x| x.eq_ignore_ascii_case("json"))) + .filter_map(|p| LabelPreset::read(&p).ok().map(|preset| (preset, Some(p)))) + .collect(); + found.sort_by_key(|(p, _)| p.name.to_lowercase()); + let mut out = vec![(LabelPreset::builtin(), None)]; + for f in found { + if !out.iter().any(|(p, _)| p.name.eq_ignore_ascii_case(&f.0.name)) { + out.push(f); + } + } + out + } + + /// Copy the preset file at `path` into the presets folder, replacing a preset of the same + /// name, and return it. One named like the built-in preset becomes " (imported)". + pub fn import_label_preset(&self, path: &Path) -> std::result::Result { + let mut p = LabelPreset::read(path)?; + if p.name.eq_ignore_ascii_case(DEFAULT_LABEL_PRESET) { + p.name = format!("{} (imported)", p.name); + } + let dir = self.label_presets_dir().ok_or("there is no presets folder in this session")?; + std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?; + for (old, file) in self.label_presets() { + if let Some(file) = file.filter(|_| old.name.eq_ignore_ascii_case(&p.name)) { + std::fs::remove_file(&file).map_err(|e| format!("{}: {e}", file.display()))?; + } + } + let safe: String = p.name.chars().map(|c| if c.is_alphanumeric() || " -_()".contains(c) { c } else { '_' }).collect(); + p.write(&dir.join(format!("{}.json", safe.trim())))?; + Ok(p) + } + + /// Delete a preset from the presets folder (the built-in one can't be deleted). + pub fn delete_label_preset(&self, name: &str) -> std::result::Result<(), String> { + let (_, file) = self.label_presets().into_iter().find(|(p, _)| p.name == name).ok_or_else(|| format!("there is no label color preset “{name}”"))?; + let file = file.ok_or("the built-in preset can't be deleted")?; + std::fs::remove_file(&file).map_err(|e| format!("{}: {e}", file.display())) } } @@ -968,6 +1132,7 @@ static CATEGORIES: &[Category] = &[ f("labels.defaults.captions", "Captions", Kind::Label, false), ], ), + Row::Custom("labelPresets"), ], }, Category { @@ -1556,10 +1721,41 @@ fn cache_clean(s: &mut Session, p: &Value) -> Result { Ok(json!({"files": files, "bytes": bytes, "location": dir.to_string_lossy()})) } +fn presets_json(s: &Session) -> Value { + let presets: Vec = s.label_presets().into_iter().map(|(p, file)| json!({"name": p.name, "builtin": file.is_none(), "labels": p.labels})).collect(); + json!({"dir": s.label_presets_dir(), "selected": s.prefs.labels.preset, "presets": presets}) +} + +fn label_presets(s: &mut Session, _: &Value) -> Result { + Ok(presets_json(s)) +} + +fn import_label_preset(s: &mut Session, p: &Value) -> Result { + let path = str_p(p, "path").ok_or_else(|| bad("labels.importPreset", "need `path`"))?; + let preset = s.import_label_preset(Path::new(path)).map_err(|e| bad("labels.importPreset", e))?; + Ok(json!({"name": preset.name, "presets": presets_json(s)})) +} + +fn export_label_preset(s: &mut Session, p: &Value) -> Result { + let path = str_p(p, "path").ok_or_else(|| bad("labels.exportPreset", "need `path`"))?; + let preset = LabelPreset::export(Path::new(path), &s.prefs.labels).map_err(|e| bad("labels.exportPreset", e))?; + Ok(json!({"name": preset.name, "path": path})) +} + +fn delete_label_preset(s: &mut Session, p: &Value) -> Result { + let name = str_p(p, "name").ok_or_else(|| bad("labels.deletePreset", "need `name`"))?; + s.delete_label_preset(name).map_err(|e| bad("labels.deletePreset", e))?; + Ok(presets_json(s)) +} + pub fn commands() -> Vec { vec![ spec("prefs.schema", "Settings Schema", r#"{"category":str?}"#, schema, false), spec("mediaCache.info", "Media Cache Info", "{}", cache_info, false), spec("mediaCache.clean", "Delete Media Cache Files", r#"{"all":bool?}"#, cache_clean, true), + spec("labels.presets", "Label Color Presets", "{}", label_presets, false), + spec("labels.importPreset", "Import Label Color Preset", r#"{"path":str}"#, import_label_preset, true), + spec("labels.exportPreset", "Export Label Color Preset", r#"{"path":str}"#, export_label_preset, true), + spec("labels.deletePreset", "Delete Label Color Preset", r#"{"name":str}"#, delete_label_preset, true), ] } diff --git a/crates/engine/src/settings_tests.rs b/crates/engine/src/settings_tests.rs index c78344057..ae53a4230 100644 --- a/crates/engine/src/settings_tests.rs +++ b/crates/engine/src/settings_tests.rs @@ -681,3 +681,94 @@ fn appearance_choices_validate_and_legacy_theme_still_selects() { } assert_eq!(seen, ["auto", "light", "dark", "auto"]); } + +fn preset_json(name: &str, n: usize, color: &str) -> Vec { + let labels: Vec = (0..n).map(|i| json!({"name": format!(" Label {i} "), "color": color})).collect(); + serde_json::to_vec(&json!({"name": name, "labels": labels})).unwrap() +} + +/// Label color preset files are checked strictly: size, exactly 16 labels, names trimmed and +/// capped, colours `#rrggbb`. A bad file is an error, never a panic or a partial preset. +#[test] +fn label_presets_reject_bad_files() { + use settings::LabelPreset; + let p = LabelPreset::parse(&preset_json(" Team ", 16, "#A0B0C0")).unwrap(); + assert_eq!(p.name, "Team"); + assert_eq!((p.labels[0].name.as_str(), p.labels[0].color.as_str()), ("Label 0", "#a0b0c0")); + let builtin = LabelPreset::builtin(); + assert_eq!(builtin.name, "Default"); + assert_eq!(builtin.labels.len(), 16); + assert!(builtin.matches(&settings::LabelPrefs::default()), "the built-in preset is today's labels"); + assert_eq!(LabelPreset::parse(&serde_json::to_vec(&builtin).unwrap()).unwrap(), builtin); + + let long = "x".repeat(65); + let mut too_big = preset_json("Big", 16, "#000000"); + too_big.extend(std::iter::repeat_n(b' ', 64 * 1024)); + for (why, bytes) in [ + ("15 labels", preset_json("A", 15, "#000000")), + ("17 labels", preset_json("A", 17, "#000000")), + ("no #", preset_json("A", 16, "000000")), + ("not hex", preset_json("A", 16, "#12345g")), + ("a colour name", preset_json("A", 16, "red")), + ("short", preset_json("A", 16, "#fff")), + ("empty name", preset_json(" ", 16, "#000000")), + ("long name", preset_json(&long, 16, "#000000")), + ( + "long label", + serde_json::to_vec(&json!({"name": "A", "labels": (0..16).map(|_| json!({"name": long, "color": "#000000"})).collect::>()})).unwrap(), + ), + ("no colour", serde_json::to_vec(&json!({"name": "A", "labels": (0..16).map(|_| json!({"name": "a"})).collect::>()})).unwrap()), + ("not an object", b"[1,2,3]".to_vec()), + ("too big", too_big), + ("not utf-8", vec![0xff, 0xfe, 0x00]), + ] { + assert!(LabelPreset::parse(&bytes).is_err(), "{why} should be refused"); + } + // every truncation of a good file is refused cleanly + let good = preset_json("Team", 16, "#102030"); + for n in 0..good.len() { + assert!(LabelPreset::parse(&good[..n]).is_err()); + } +} + +/// Import copies a preset into `Label Presets` beside the preferences, export writes the current +/// labels named after the file, and delete removes user presets only. A bad file changes nothing. +#[test] +fn label_presets_import_export_and_delete() { + let dir = tmp_dir("label-presets"); + let mut s = Session { prefs_path: Some(dir.join("preferences.json")), ..Session::default() }; + let names = |s: &mut Session| -> Vec { + let v = s.execute("labels.presets", json!({})).unwrap(); + v["presets"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap().to_string()).collect() + }; + assert_eq!(names(&mut s), ["Default"]); + let src = dir.join("from someone.json"); + std::fs::write(&src, preset_json("Team", 16, "#102030")).unwrap(); + let r = s.execute("labels.importPreset", json!({"path": src.to_string_lossy()})).unwrap(); + assert_eq!(r["name"], "Team"); + assert!(dir.join("Label Presets").join("Team.json").is_file()); + assert_eq!(names(&mut s), ["Default", "Team"]); + + // a bad file: an error, and the folder is as it was + let bad = dir.join("bad.json"); + std::fs::write(&bad, preset_json("Bad", 3, "#102030")).unwrap(); + assert!(s.execute("labels.importPreset", json!({"path": bad.to_string_lossy()})).is_err()); + std::fs::write(dir.join("Label Presets").join("junk.json"), b"{not json").unwrap(); + assert_eq!(names(&mut s), ["Default", "Team"], "unreadable files in the folder are skipped"); + + // a preset named like the built-in one keeps both + std::fs::write(&src, preset_json("default", 16, "#405060")).unwrap(); + assert_eq!(s.execute("labels.importPreset", json!({"path": src.to_string_lossy()})).unwrap()["name"], "default (imported)"); + + // export writes the applied labels, named after the file, and imports back + set(&mut s, "labels.colors.rose.color", json!("#123456")); + let out = dir.join("Our Labels.json"); + assert_eq!(s.execute("labels.exportPreset", json!({"path": out.to_string_lossy()})).unwrap()["name"], "Our Labels"); + let back = settings::LabelPreset::read(&out).unwrap(); + assert!(back.matches(&s.prefs.labels)); + + assert!(s.execute("labels.deletePreset", json!({"name": "Default"})).is_err(), "the built-in preset stays"); + s.execute("labels.deletePreset", json!({"name": "Team"})).unwrap(); + assert_eq!(names(&mut s), ["Default", "default (imported)"]); + assert!(s.execute("labels.deletePreset", json!({"name": "Team"})).is_err()); +} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 1a8a965c9..9ecd4a5ec 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3569,3 +3569,10 @@ The default graphics are used from the next start. Los gráficos predeterminados se usarán a partir del próximo inicio. DirectX 12 only Solo DirectX 12 software rendering renderizado por software + Label Color Presets Ajustes preestablecidos de color de etiqueta + {name} (Modified) {name} (modificado) + Revert to the preset's colors and names Volver a los colores y nombres del ajuste preestablecido + Import a preset file… Importar un archivo de ajuste preestablecido… + Export the labels to a preset file… Exportar las etiquetas a un archivo de ajuste preestablecido… + Show the presets folder Mostrar la carpeta de ajustes preestablecidos + Delete this preset Eliminar este ajuste preestablecido diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index e40c3eeb8..ef315bda6 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3552,3 +3552,10 @@ The default graphics are used from the next start. Les graphismes par défaut seront utilisés au prochain démarrage. DirectX 12 only DirectX 12 uniquement software rendering rendu logiciel + Label Color Presets Paramètres prédéfinis de couleur d'étiquette + {name} (Modified) {name} (modifié) + Revert to the preset's colors and names Revenir aux couleurs et aux noms du paramètre prédéfini + Import a preset file… Importer un fichier de paramètre prédéfini… + Export the labels to a preset file… Exporter les étiquettes dans un fichier de paramètre prédéfini… + Show the presets folder Afficher le dossier des paramètres prédéfinis + Delete this preset Supprimer ce paramètre prédéfini diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 3d1e44988..ff8afbc2f 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3551,3 +3551,10 @@ The default graphics are used from the next start. 次回の起動からデフォルトのグラフィックが使用されます。 DirectX 12 only DirectX 12 のみ software rendering ソフトウェアレンダリング + Label Color Presets ラベルカラープリセット + {name} (Modified) {name} (変更あり) + Revert to the preset's colors and names プリセットのカラーと名前に戻す + Import a preset file… プリセットファイルを読み込み… + Export the labels to a preset file… ラベルをプリセットファイルに書き出し… + Show the presets folder プリセットフォルダーを表示 + Delete this preset このプリセットを削除 diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index bc386a826..20cd07683 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -3547,3 +3547,10 @@ The default graphics are used from the next start. 下次启动时将使用默认图形。 DirectX 12 only 仅 DirectX 12 software rendering 软件渲染 + Label Color Presets 标签颜色预设 + {name} (Modified) {name}(已修改) + Revert to the preset's colors and names 恢复为预设的颜色和名称 + Import a preset file… 导入预设文件… + Export the labels to a preset file… 将标签导出为预设文件… + Show the presets folder 显示预设文件夹 + Delete this preset 删除此预设 diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index b8be330d8..230a84a77 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -73,6 +73,8 @@ pub enum Icon { Close, Fullscreen, Export, + /// An arrow down into a tray (Import). + Import, Gear, Info, Captions, @@ -578,6 +580,11 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) { pen.line(&[(5.0, 5.0), (8.0, 2.0), (11.0, 5.0)]); pen.line(&[(3.0, 8.0), (3.0, 14.0), (13.0, 14.0), (13.0, 8.0)]); } + Import => { + pen.line(&[(8.0, 2.0), (8.0, 10.0)]); + pen.line(&[(5.0, 7.0), (8.0, 10.0), (11.0, 7.0)]); + pen.line(&[(3.0, 8.0), (3.0, 14.0), (13.0, 14.0), (13.0, 8.0)]); + } Gear => { pen.circle(8.0, 8.0, 2.2); for i in 0..8 { @@ -866,6 +873,7 @@ mod tests { Close, Fullscreen, Export, + Import, Gear, Info, Captions, diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 336ba0970..4faf850f1 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -17,6 +17,7 @@ use filmcraft_project::Label; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value, json}; +use crate::icons::Icon; use crate::theme::Tokens; use crate::{AudioDevices, Dialog, FilmcraftApp}; @@ -42,6 +43,12 @@ pub struct SettingsDraft { /// Audio devices reported by the platform (Audio Hardware page). #[serde(default)] pub devices: Option, + /// Label color presets found when the Labels page was shown (the built-in one first). + #[serde(default)] + pub label_presets: Option>, + /// Result of the last Label Color Presets button, or its error. + #[serde(default)] + pub preset_message: String, } /// Open the dialog on a category page. @@ -552,6 +559,93 @@ fn color_field(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, key: } } +/// Settings ▸ Labels ▸ Label Color Presets: take the label names and colours from a preset, and +/// share presets as files. Picking a preset or Revert edits the draft (OK applies it, Cancel +/// discards it); Import, Export and Delete act on the files at once. +fn label_presets(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft) { + let t = app.tokens; + let presets = d.label_presets.get_or_insert_with(|| app.session.label_presets().into_iter().map(|(p, _)| p).collect()).clone(); + let labels: settings::LabelPrefs = serde_json::from_value(get(&d.values, "labels").clone()).unwrap_or_default(); + let selected = presets.iter().find(|p| p.name == labels.preset); + let modified = selected.is_some_and(|p| !p.matches(&labels)); + let folder = app.session.label_presets_dir(); + let mut pick = None; + group(ui, &t, tl!("Label Color Presets"), |ui| { + ui.horizontal(|ui| { + let opts: Vec<(String, String)> = presets.iter().map(|p| (p.name.clone(), p.name.clone())).collect(); + let shown = if modified { tlf!("{name} (Modified)", name = labels.preset) } else { labels.preset.clone() }; + if let Some(name) = combo(app, ui, "labels.preset", &shown, &opts, 150.0) { + pick = presets.iter().find(|p| p.name == name).cloned(); + } + if preset_button(app, ui, "revert", Icon::Reset, tl!("Revert to the preset's colors and names"), modified) { + pick = selected.cloned(); + } + if preset_button(app, ui, "import", Icon::Import, tl!("Import a preset file…"), true) + && let Some(path) = app.hooks.pick_open_file.as_mut().and_then(|f| f(tl!("Label Color Presets"), &["json"])) + { + d.label_presets = None; + match app.session.import_label_preset(std::path::Path::new(&path)) { + Ok(p) => { + d.preset_message = tlf!("Imported “{name}”", name = p.name); + pick = Some(p); + } + Err(e) => d.preset_message = e, + } + } + if preset_button(app, ui, "export", Icon::Export, tl!("Export the labels to a preset file…"), true) + && let Some(path) = app.hooks.pick_save_as.as_mut().and_then(|f| f(tl!("Label Color Presets"), &["json"], &format!("{}.json", labels.preset))) + { + d.preset_message = match settings::LabelPreset::export(std::path::Path::new(&path), &labels) { + Ok(_) => tlf!("Exported to {path}", path), + Err(e) => e, + }; + } + if preset_button(app, ui, "folder", Icon::Folder, tl!("Show the presets folder"), folder.is_some()) + && let Some(dir) = &folder + { + let ctx = ui.ctx().clone(); + if let Err(e) = std::fs::create_dir_all(dir) + .map_err(|e| e.to_string()) + .and_then(|()| super::menu_dialogs::open_path(app, &ctx, &dir.to_string_lossy(), false)) + { + d.preset_message = e; + } + } + let user = selected.filter(|p| p.name != settings::DEFAULT_LABEL_PRESET); + if preset_button(app, ui, "delete", Icon::Trash, tl!("Delete this preset"), user.is_some()) + && let Some(p) = user + { + d.label_presets = None; + d.preset_message = match app.session.delete_label_preset(&p.name) { + Ok(()) => { + put(&mut d.values, "labels.preset", json!(settings::DEFAULT_LABEL_PRESET)); + tlf!("Deleted preset “{active}”", active = p.name) + } + Err(e) => e, + }; + } + }); + if !d.preset_message.is_empty() { + note(ui, &t, &d.preset_message); + } + }); + if let Some(p) = pick { + let mut labels = labels; + p.apply(&mut labels); + if let Ok(v) = serde_json::to_value(&labels) { + put(&mut d.values, "labels", v); + } + } +} + +/// An icon button of the Label Color Presets row (`settings.labelPresets.`). +fn preset_button(app: &mut FilmcraftApp, ui: &mut Ui, id: &str, icon: Icon, tip: &str, enabled: bool) -> bool { + let t = app.tokens; + let r = ui.add_enabled_ui(enabled, |ui| crate::icons::button(ui, icon, 24.0, false, &t, tip)).inner; + app.auto.add(&format!("settings.labelPresets.{id}"), r.rect, tip); + r.clicked() +} + fn note(ui: &mut Ui, t: &Tokens, text: &str) { ui.add(egui::Label::new(RichText::new(crate::i18n::t(text)).size(11.5).color(t.text_dim)).wrap()); } @@ -619,6 +713,7 @@ fn custom(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, name: &str ); } "appearanceModes" => appearance_modes(app, ui, d), + "labelPresets" => label_presets(app, ui, d), "autoSaveStatus" => { let status = app.session.execute("file.autoSaveStatus", json!({})).unwrap_or_default(); if let Some(dir) = status["autoSaveDir"].as_str() { @@ -643,25 +738,19 @@ fn custom(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, name: &str fn rows(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, list: &[Row]) { let t = app.tokens; - let mut skip = false; for (i, r) in list.iter().enumerate() { - if std::mem::take(&mut skip) { - continue; - } - // Labels: Label Colors on the left, the row after it (Label Defaults) on the right + // Labels: Label Colors on the left, the rest of the page (Label Defaults, Label Color + // Presets) on the right if matches!(r, Row::Custom("labelColors")) { - let next = list.get(i + 1).copied(); + let rest = list.get(i + 1..).unwrap_or_default(); ui.horizontal_top(|ui| { ui.allocate_ui(vec2(250.0, 0.0), |ui| { ui.vertical(|ui| custom(app, ui, d, "labelColors")); }); ui.add_space(12.0); - if let Some(n) = next { - ui.vertical(|ui| rows(app, ui, d, &[n])); - } + ui.vertical(|ui| rows(app, ui, d, rest)); }); - skip = true; - continue; + break; } match r { Row::Field(f) if APPEARANCE_KEYS.contains(&f.key) => continue, diff --git a/crates/ui-egui/tests/settings_ui.rs b/crates/ui-egui/tests/settings_ui.rs index e82677464..a7406e86a 100644 --- a/crates/ui-egui/tests/settings_ui.rs +++ b/crates/ui-egui/tests/settings_ui.rs @@ -461,3 +461,106 @@ fn playhead_jumps_to_a_press_on_the_ruler() { button(&mut d, false); assert_eq!(d.harness.state().session.playhead(), ph, "the release leaves it there"); } + +/// Settings ▸ Labels ▸ Label Color Presets: the dropdown shows "(Modified)" once the labels differ +/// from the preset and Revert brings them back; Import copies a preset file into the presets folder +/// beside the preferences and selects it (a bad file only shows an error); Export writes the +/// dialog's labels; Show Folder opens the folder; Delete removes user presets only. Choosing a +/// preset applies on OK like the other fields, and Cancel discards it. +#[test] +fn label_color_presets() { + use std::sync::{Arc, Mutex}; + let mut d = Driver::demo(); + let dir = std::env::temp_dir().join(format!("filmcraft-label-presets-ui-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let folder = dir.join("Label Presets"); + let picked: Arc>> = Arc::default(); + let opened: Arc>> = Arc::default(); + { + let app = d.harness.state_mut(); + app.session.prefs_path = Some(dir.join("preferences.json")); + let p = picked.clone(); + app.hooks.pick_open_file = Some(Box::new(move |_, _| p.lock().unwrap().clone())); + let p = picked.clone(); + app.hooks.pick_save_as = Some(Box::new(move |_, _, _| p.lock().unwrap().clone())); + let o = opened.clone(); + app.hooks.open_path = Some(Box::new(move |path, _| { + o.lock().unwrap().push(path.to_string()); + Ok(()) + })); + } + let label = |d: &mut Driver, id: &str| -> String { + let v = d.ok("ui.elements", json!({"prefix": id})); + v.as_array().unwrap().iter().find(|e| e["id"] == id).and_then(|e| e["label"].as_str().map(str::to_string)).unwrap_or_default() + }; + let preset = |name: &str, color: &str| { + let labels: Vec = (0..16).map(|i| json!({"name": format!("{name} {i}"), "color": color})).collect(); + serde_json::to_vec(&json!({"name": name, "labels": labels})).unwrap() + }; + + d.menu("app.settings.labels"); + assert_eq!(label(&mut d, "settings.labels.preset"), "Default"); + d.ok("ui.set", json!({"settings": {"values": {"labels.colors.rose.name": "Hero"}}})); + d.frames(2); + assert_eq!(label(&mut d, "settings.labels.preset"), "Default (Modified)"); + d.click("settings.labelPresets.revert"); + assert_eq!(d.draft()["values"]["labels"]["colors"]["rose"]["name"], "Rose"); + assert_eq!(label(&mut d, "settings.labels.preset"), "Default"); + + // a bad file: an error in the dialog, nothing changes + let bad = dir.join("bad.json"); + std::fs::write(&bad, preset("Bad", "#zzzzzz")).unwrap(); + *picked.lock().unwrap() = Some(bad.to_string_lossy().into()); + let before = d.draft()["values"]["labels"].clone(); + d.click("settings.labelPresets.import"); + assert!(d.draft()["preset_message"].as_str().unwrap().contains("color"), "{}", d.draft()["preset_message"]); + assert_eq!(d.draft()["values"]["labels"], before); + assert!(!folder.join("Bad.json").exists()); + + // a good one is copied into the folder and selected + let good = dir.join("from a colleague.json"); + std::fs::write(&good, preset("Team", "#102030")).unwrap(); + *picked.lock().unwrap() = Some(good.to_string_lossy().into()); + d.click("settings.labelPresets.import"); + assert!(folder.join("Team.json").is_file()); + assert_eq!(label(&mut d, "settings.labels.preset"), "Team"); + assert_eq!(d.draft()["values"]["labels"]["colors"]["rose"], json!({"name": "Team 6", "color": "#102030"})); + d.ok("ui.set", json!({"settings": {"values": {"labels.colors.rose.name": "Hero"}}})); + d.shot("labels-presets"); + d.click("settings.labelPresets.revert"); + + // export writes what the dialog shows + d.ok("ui.set", json!({"settings": {"values": {"labels.colors.violet.color": "#abcdef"}}})); + let out = dir.join("Shared.json"); + *picked.lock().unwrap() = Some(out.to_string_lossy().into()); + d.click("settings.labelPresets.export"); + let written: Value = serde_json::from_slice(&std::fs::read(&out).unwrap()).unwrap(); + assert_eq!(written["name"], "Shared"); + assert_eq!(written["labels"][0], json!({"name": "Team 0", "color": "#abcdef"})); + + d.click("settings.labelPresets.folder"); + assert_eq!(*opened.lock().unwrap(), [folder.to_string_lossy().to_string()]); + + // Cancel discards the choice; picking it again and OK applies it + d.click("settings.cancel"); + assert_eq!(d.pref("labels.preset"), "Default"); + assert_eq!(d.pref("labels.colors.rose.name"), "Rose"); + d.menu("app.settings.labels"); + d.click("settings.labels.preset"); + d.click("settings.labels.preset.Team"); + d.click("settings.ok"); + assert_eq!(d.pref("labels.preset"), "Team"); + assert_eq!(d.pref("labels.colors.rose.color"), "#102030"); + + // Delete: user presets only + d.menu("app.settings.labels"); + d.click("settings.labelPresets.delete"); + assert!(!folder.join("Team.json").exists()); + assert_eq!(label(&mut d, "settings.labels.preset"), "Default (Modified)", "the colours stay until reverted"); + std::fs::write(folder.join("keep.json"), preset("Keep", "#405060")).unwrap(); + d.click("settings.labelPresets.delete"); + assert!(folder.join("keep.json").exists(), "the built-in preset can't be deleted, and nothing else goes"); + d.click("settings.cancel"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/docs/project-files.md b/docs/project-files.md index 9f5be7285..e8042e3a8 100644 --- a/docs/project-files.md +++ b/docs/project-files.md @@ -223,7 +223,7 @@ opens the dialog on a page. | Auto Save | the auto-save ring and the crash-recovery journal | | Color | stored only (display colour management, EDR monitoring, HDR graphics white) | | Graphics | new text layers: smart quotes, ligatures, default font | -| Labels | the 16 label names and colours (timeline, Project panel, Edit ▸ Label), label defaults for imported movies / video / audio / stills and new sequences | +| Labels | the 16 label names and colours (timeline, Project panel, Edit ▸ Label), label defaults for imported movies / video / audio / stills and new sequences, Label Color Presets: JSON files (`{"name", "labels": [16 × {"name", "color": "#rrggbb"}]}`) in `/Label Presets`, the built-in "Default" being the labels FilmCraft ships with (`labels.presets`, `labels.importPreset {path}`, `labels.exportPreset {path}`, `labels.deletePreset {name}`; `labels.preset` is the selected one) | | Media | Indeterminate Media Timebase (frame rate of stills), Default Media Scaling (Scale to / Set to frame size when a clip of another size is edited in), Enable proxies | | Media Analysis & Transcription | auto-transcribe imported clips (scope "all imported"), speech model, default language / auto-detect, speaker labelling (defaults of `transcript.generate`) | | Media Cache | location (`/Media Cache` by default; unsaved projects' render previews live there), automatic deletion (older than N days / beyond N GB), Delete… (`mediaCache.clean {all?}`, `mediaCache.info`) | From acfea6e2d06f654d02eb570b8f8338bdbdb6ae81 Mon Sep 17 00:00:00 2001 From: ReReddo <249398616+ReReddo@users.noreply.github.com> Date: Sun, 11 Oct 2026 12:12:52 +0300 Subject: [PATCH 255/260] labels: Bright and Roles built-in label color presets Two more built-in presets follow Default in Settings > Labels > Label Color Presets. Bright is 16 saturated hues spaced around the colour wheel plus a gray, named Red to Gray. Roles names the labels after what clips are (B-Roll, Footage, Dialogue, Graphics, Interview, Sequence, Visual Effects, Bin, Titles, Music, Sound Effects, Voice-Over, Captions, Ambience, Color Grade, Stock), ordered so the label defaults for new video, movies, audio, stills, sequences, Dynamic Link items, bins and captions land on the matching role. Like Default they can be chosen and reverted to but not deleted, and an imported preset with one of their names is renamed " (imported)". --- crates/engine/src/settings.rs | 74 ++++++++++++++++++++++++--- crates/engine/src/settings_tests.rs | 63 ++++++++++++++++++++--- crates/ui-egui/src/panels/settings.rs | 2 +- crates/ui-egui/tests/settings_ui.rs | 24 +++++++++ docs/project-files.md | 2 +- 5 files changed, 151 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/settings.rs b/crates/engine/src/settings.rs index 8f92c7b1a..8efdf3ea7 100644 --- a/crates/engine/src/settings.rs +++ b/crates/engine/src/settings.rs @@ -423,12 +423,74 @@ pub struct PresetLabel { pub color: String, } +/// The built-in presets after [`DEFAULT_LABEL_PRESET`]: (name, 16 × (label name, colour)) in +/// [`Label::ALL`] order. "Roles" puts the label defaults (Video, Movie, Audio, Still, Sequence, +/// Dynamic Link, Bin / Captions on labels 0, 1, 2, 3, 5, 6, 7) on matching roles. +const BUILTIN_LABEL_PRESETS: [(&str, [(&str, &str); 16]); 2] = [ + ( + "Bright", + [ + ("Red", "#e3342f"), + ("Orange", "#ec7a24"), + ("Amber", "#f0a82a"), + ("Yellow", "#e8d422"), + ("Lime", "#9ed62a"), + ("Green", "#36c24a"), + ("Mint", "#2fd69a"), + ("Cyan", "#24c9dc"), + ("Sky", "#2fa0ec"), + ("Blue", "#3d6ff0"), + ("Indigo", "#5b5cf0"), + ("Violet", "#8452ec"), + ("Purple", "#ac46e0"), + ("Magenta", "#dc3ccc"), + ("Pink", "#ec4f8e"), + ("Gray", "#8c8c8c"), + ], + ), + ( + "Roles", + [ + ("B-Roll", "#5b8fd4"), + ("Footage", "#36a9c9"), + ("Dialogue", "#4cb87a"), + ("Graphics", "#e8913a"), + ("Interview", "#d9c040"), + ("Sequence", "#7f9e3c"), + ("Visual Effects", "#c95bc4"), + ("Bin", "#8a94a6"), + ("Titles", "#e86f5c"), + ("Music", "#8c6fe0"), + ("Sound Effects", "#e0609a"), + ("Voice-Over", "#5fd1b5"), + ("Captions", "#c8b48a"), + ("Ambience", "#a8754f"), + ("Color Grade", "#d64545"), + ("Stock", "#b5d454"), + ], + ), +]; + +/// Whether `name` is a built-in preset's (any case): built-ins can't be deleted or replaced. +pub fn is_builtin_label_preset(name: &str) -> bool { + name.eq_ignore_ascii_case(DEFAULT_LABEL_PRESET) || BUILTIN_LABEL_PRESETS.iter().any(|(n, _)| name.eq_ignore_ascii_case(n)) +} + impl LabelPreset { /// The built-in preset ([`DEFAULT_LABEL_PRESET`]). pub fn builtin() -> Self { Self::from_prefs(DEFAULT_LABEL_PRESET, &LabelPrefs::default()) } + /// Every built-in preset, [`DEFAULT_LABEL_PRESET`] first. + pub fn builtins() -> Vec { + let more = BUILTIN_LABEL_PRESETS.iter().map(|(name, labels)| Self { + name: (*name).into(), + labels: labels.iter().map(|(n, c)| PresetLabel { name: (*n).into(), color: (*c).into() }).collect(), + }); + std::iter::once(Self::builtin()).chain(more).collect() + } + /// `labels`' names and colours as a preset called `name`. pub fn from_prefs(name: &str, labels: &LabelPrefs) -> Self { let entry = |l: Label| labels.colors.get(&label_id(l)).cloned().unwrap_or_else(|| default_label_color(l)); @@ -502,7 +564,7 @@ impl Session { self.prefs_path.as_deref().and_then(Path::parent).map(|d| d.join(LABEL_PRESETS_DIR)) } - /// The built-in preset, then the presets in the folder by name with the file each is in. + /// The built-in presets, then the presets in the folder by name with the file each is in. /// Files that fail [`LabelPreset::parse`] and names already taken are left out. pub fn label_presets(&self) -> Vec<(LabelPreset, Option)> { let mut found: Vec<(LabelPreset, Option)> = self @@ -517,7 +579,7 @@ impl Session { .filter_map(|p| LabelPreset::read(&p).ok().map(|preset| (preset, Some(p)))) .collect(); found.sort_by_key(|(p, _)| p.name.to_lowercase()); - let mut out = vec![(LabelPreset::builtin(), None)]; + let mut out: Vec<(LabelPreset, Option)> = LabelPreset::builtins().into_iter().map(|p| (p, None)).collect(); for f in found { if !out.iter().any(|(p, _)| p.name.eq_ignore_ascii_case(&f.0.name)) { out.push(f); @@ -527,10 +589,10 @@ impl Session { } /// Copy the preset file at `path` into the presets folder, replacing a preset of the same - /// name, and return it. One named like the built-in preset becomes " (imported)". + /// name, and return it. One named like a built-in preset becomes " (imported)". pub fn import_label_preset(&self, path: &Path) -> std::result::Result { let mut p = LabelPreset::read(path)?; - if p.name.eq_ignore_ascii_case(DEFAULT_LABEL_PRESET) { + if is_builtin_label_preset(&p.name) { p.name = format!("{} (imported)", p.name); } let dir = self.label_presets_dir().ok_or("there is no presets folder in this session")?; @@ -545,10 +607,10 @@ impl Session { Ok(p) } - /// Delete a preset from the presets folder (the built-in one can't be deleted). + /// Delete a preset from the presets folder (built-in presets can't be deleted). pub fn delete_label_preset(&self, name: &str) -> std::result::Result<(), String> { let (_, file) = self.label_presets().into_iter().find(|(p, _)| p.name == name).ok_or_else(|| format!("there is no label color preset “{name}”"))?; - let file = file.ok_or("the built-in preset can't be deleted")?; + let file = file.ok_or("built-in presets can't be deleted")?; std::fs::remove_file(&file).map_err(|e| format!("{}: {e}", file.display())) } } diff --git a/crates/engine/src/settings_tests.rs b/crates/engine/src/settings_tests.rs index ae53a4230..6e31387b4 100644 --- a/crates/engine/src/settings_tests.rs +++ b/crates/engine/src/settings_tests.rs @@ -741,24 +741,26 @@ fn label_presets_import_export_and_delete() { let v = s.execute("labels.presets", json!({})).unwrap(); v["presets"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap().to_string()).collect() }; - assert_eq!(names(&mut s), ["Default"]); + assert_eq!(names(&mut s), ["Default", "Bright", "Roles"]); let src = dir.join("from someone.json"); std::fs::write(&src, preset_json("Team", 16, "#102030")).unwrap(); let r = s.execute("labels.importPreset", json!({"path": src.to_string_lossy()})).unwrap(); assert_eq!(r["name"], "Team"); assert!(dir.join("Label Presets").join("Team.json").is_file()); - assert_eq!(names(&mut s), ["Default", "Team"]); + assert_eq!(names(&mut s), ["Default", "Bright", "Roles", "Team"]); // a bad file: an error, and the folder is as it was let bad = dir.join("bad.json"); std::fs::write(&bad, preset_json("Bad", 3, "#102030")).unwrap(); assert!(s.execute("labels.importPreset", json!({"path": bad.to_string_lossy()})).is_err()); std::fs::write(dir.join("Label Presets").join("junk.json"), b"{not json").unwrap(); - assert_eq!(names(&mut s), ["Default", "Team"], "unreadable files in the folder are skipped"); + assert_eq!(names(&mut s), ["Default", "Bright", "Roles", "Team"], "unreadable files in the folder are skipped"); - // a preset named like the built-in one keeps both + // a preset named like a built-in one keeps both std::fs::write(&src, preset_json("default", 16, "#405060")).unwrap(); assert_eq!(s.execute("labels.importPreset", json!({"path": src.to_string_lossy()})).unwrap()["name"], "default (imported)"); + std::fs::write(&src, preset_json("ROLES", 16, "#405060")).unwrap(); + assert_eq!(s.execute("labels.importPreset", json!({"path": src.to_string_lossy()})).unwrap()["name"], "ROLES (imported)"); // export writes the applied labels, named after the file, and imports back set(&mut s, "labels.colors.rose.color", json!("#123456")); @@ -767,8 +769,57 @@ fn label_presets_import_export_and_delete() { let back = settings::LabelPreset::read(&out).unwrap(); assert!(back.matches(&s.prefs.labels)); - assert!(s.execute("labels.deletePreset", json!({"name": "Default"})).is_err(), "the built-in preset stays"); + for builtin in ["Default", "Bright", "Roles"] { + assert!(s.execute("labels.deletePreset", json!({"name": builtin})).is_err(), "{builtin} stays"); + } s.execute("labels.deletePreset", json!({"name": "Team"})).unwrap(); - assert_eq!(names(&mut s), ["Default", "default (imported)"]); + assert_eq!(names(&mut s), ["Default", "Bright", "Roles", "default (imported)", "ROLES (imported)"]); assert!(s.execute("labels.deletePreset", json!({"name": "Team"})).is_err()); } + +/// The built-in presets: Default (the labels as shipped), Bright and Roles, each with 16 distinct +/// names and valid colours (Bright's and Roles' clearly different); Roles puts the label defaults +/// on matching roles. +#[test] +fn builtin_label_presets_are_complete_and_distinct() { + use settings::LabelPreset; + let all = LabelPreset::builtins(); + assert_eq!(all.iter().map(|p| p.name.as_str()).collect::>(), ["Default", "Bright", "Roles"]); + for p in &all { + assert!(settings::is_builtin_label_preset(&p.name)); + assert_eq!(&LabelPreset::parse(&serde_json::to_vec(p).unwrap()).unwrap(), p, "{} is a valid preset file", p.name); + let mut names: Vec<&str> = p.labels.iter().map(|l| l.name.as_str()).collect(); + names.sort(); + names.dedup(); + assert_eq!(names.len(), 16, "{}: names are unique", p.name); + let rgb: Vec<[u8; 3]> = p.labels.iter().map(|l| settings::parse_hex(&l.color).unwrap()).collect(); + if p.name == "Default" { + continue; // the colours labels have always had + } + for (i, a) in rgb.iter().enumerate() { + for b in &rgb[i + 1..] { + let d: f32 = a.iter().zip(b).map(|(x, y)| (f32::from(*x) - f32::from(*y)).powi(2)).sum::().sqrt(); + assert!(d > 30.0, "{}: {a:?} and {b:?} are too close", p.name); + } + } + } + let roles = &all[2]; + let mut labels = settings::LabelPrefs::default(); + roles.apply(&mut labels); + assert!(roles.matches(&labels)); + let d = labels.defaults.clone(); + let named = [ + (d.video, "B-Roll"), + (d.movie, "Footage"), + (d.audio, "Dialogue"), + (d.still, "Graphics"), + (d.sequence, "Sequence"), + (d.dynamic_link, "Visual Effects"), + (d.bin, "Bin"), + (d.captions, "Bin"), + ]; + for (label, role) in named { + assert_eq!(labels.name(label), role); + } + assert!(!settings::is_builtin_label_preset("Team")); +} diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 4faf850f1..dc89e2dc6 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -611,7 +611,7 @@ fn label_presets(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft) { d.preset_message = e; } } - let user = selected.filter(|p| p.name != settings::DEFAULT_LABEL_PRESET); + let user = selected.filter(|p| !settings::is_builtin_label_preset(&p.name)); if preset_button(app, ui, "delete", Icon::Trash, tl!("Delete this preset"), user.is_some()) && let Some(p) = user { diff --git a/crates/ui-egui/tests/settings_ui.rs b/crates/ui-egui/tests/settings_ui.rs index a7406e86a..ec1144364 100644 --- a/crates/ui-egui/tests/settings_ui.rs +++ b/crates/ui-egui/tests/settings_ui.rs @@ -508,6 +508,30 @@ fn label_color_presets() { assert_eq!(d.draft()["values"]["labels"]["colors"]["rose"]["name"], "Rose"); assert_eq!(label(&mut d, "settings.labels.preset"), "Default"); + // the other built-ins come after Default, can be chosen and reverted to, and can't be deleted + d.click("settings.labels.preset"); + let items = d.ok("ui.elements", json!({"prefix": "settings.labels.preset."})); + let order: Vec = + items.as_array().unwrap().iter().filter_map(|e| e["id"].as_str()?.strip_prefix("settings.labels.preset.").map(str::to_string)).collect(); + assert_eq!(order, ["Default", "Bright", "Roles"]); + d.click("settings.labels.preset.Roles"); + assert_eq!(label(&mut d, "settings.labels.preset"), "Roles"); + assert_eq!(d.draft()["values"]["labels"]["colors"]["violet"], json!({"name": "B-Roll", "color": "#5b8fd4"})); + d.click("settings.labels.preset"); + d.click("settings.labels.preset.Bright"); + assert_eq!(d.draft()["values"]["labels"]["colors"]["rose"], json!({"name": "Mint", "color": "#2fd69a"})); + d.ok("ui.set", json!({"settings": {"values": {"labels.colors.rose.color": "#000000"}}})); + d.frames(2); + assert_eq!(label(&mut d, "settings.labels.preset"), "Bright (Modified)"); + d.click("settings.labelPresets.revert"); + assert_eq!(d.draft()["values"]["labels"]["colors"]["rose"]["color"], "#2fd69a"); + d.click("settings.labelPresets.delete"); + assert_eq!(label(&mut d, "settings.labels.preset"), "Bright", "built-ins can't be deleted"); + assert_eq!(d.draft()["preset_message"], ""); + d.click("settings.labels.preset"); + d.click("settings.labels.preset.Default"); + assert_eq!(d.draft()["values"]["labels"]["colors"]["rose"], json!({"name": "Rose", "color": d.pref("labels.colors.rose.color")})); + // a bad file: an error in the dialog, nothing changes let bad = dir.join("bad.json"); std::fs::write(&bad, preset("Bad", "#zzzzzz")).unwrap(); diff --git a/docs/project-files.md b/docs/project-files.md index e8042e3a8..0298571cf 100644 --- a/docs/project-files.md +++ b/docs/project-files.md @@ -223,7 +223,7 @@ opens the dialog on a page. | Auto Save | the auto-save ring and the crash-recovery journal | | Color | stored only (display colour management, EDR monitoring, HDR graphics white) | | Graphics | new text layers: smart quotes, ligatures, default font | -| Labels | the 16 label names and colours (timeline, Project panel, Edit ▸ Label), label defaults for imported movies / video / audio / stills and new sequences, Label Color Presets: JSON files (`{"name", "labels": [16 × {"name", "color": "#rrggbb"}]}`) in `/Label Presets`, the built-in "Default" being the labels FilmCraft ships with (`labels.presets`, `labels.importPreset {path}`, `labels.exportPreset {path}`, `labels.deletePreset {name}`; `labels.preset` is the selected one) | +| Labels | the 16 label names and colours (timeline, Project panel, Edit ▸ Label), label defaults for imported movies / video / audio / stills and new sequences, Label Color Presets: JSON files (`{"name", "labels": [16 × {"name", "color": "#rrggbb"}]}`) in `/Label Presets`, built in: "Default" (the labels FilmCraft ships with), "Bright" (16 saturated hues) and "Roles" (B-Roll, Footage, Dialogue… named so the label defaults land on matching roles) (`labels.presets`, `labels.importPreset {path}`, `labels.exportPreset {path}`, `labels.deletePreset {name}`; `labels.preset` is the selected one) | | Media | Indeterminate Media Timebase (frame rate of stills), Default Media Scaling (Scale to / Set to frame size when a clip of another size is edited in), Enable proxies | | Media Analysis & Transcription | auto-transcribe imported clips (scope "all imported"), speech model, default language / auto-detect, speaker labelling (defaults of `transcript.generate`) | | Media Cache | location (`/Media Cache` by default; unsaved projects' render previews live there), automatic deletion (older than N days / beyond N GB), Delete… (`mediaCache.clean {all?}`, `mediaCache.info`) | From c8275f294bf45c7fad9a4718196bd1962a8bc73f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 20:21:35 -0400 Subject: [PATCH 256/260] Trim mode rolls each selected edit point once (fixes the #870 regression) timeline.roll follows linked partners since #870, but Trim mode already selects the linked audio edit point next to the video one and rolls every selected point, so a linked cut moved twice (trim_mode_roll_ripple_and_toggle: +10 frames instead of +5). timeline.roll takes an optional followLinks (default true); Trim mode passes false. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands.rs | 5 +++-- crates/engine/src/trim.rs | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 90ed841de..20925268f 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -2498,7 +2498,7 @@ fn build() -> Vec { s, &json!({"side": "next", "ripple": false}) )), - cmd!("timeline.roll", "Rolling Edit", [], None, r#"{"left":id,"right":id,"delta":ticks|"deltaFrames":i64}"#, has_seq, |s, p| { + cmd!("timeline.roll", "Rolling Edit", [], None, r#"{"left":id,"right":id,"delta":ticks|"deltaFrames":i64,"followLinks"?:bool}"#, has_seq, |s, p| { let (l, r) = ( clip_p(p, "left").ok_or_else(|| bad("timeline.roll", "need `left`"))?, clip_p(p, "right").ok_or_else(|| bad("timeline.roll", "need `right`"))?, @@ -2509,7 +2509,8 @@ fn build() -> Vec { .map(Tick) .or_else(|| p.get("deltaFrames").and_then(Value::as_i64).map(|f| s.sequence_rate().tick_of(f))) .unwrap_or_default(); - let follow = s.state.linked_selection; + // `followLinks: false` rolls only this pair: Trim mode already lists linked partners as edit points of their own + let follow = s.state.linked_selection && p.get("followLinks").and_then(Value::as_bool).unwrap_or(true); let (x, n, linked) = s.edit_sequence("Rolling Edit", |q, ctx, _| { let pairs = linked_roll_pairs(q, l, r, follow); let linked = pairs.len() > 1; diff --git a/crates/engine/src/trim.rs b/crates/engine/src/trim.rs index 951a873c8..22315157e 100644 --- a/crates/engine/src/trim.rs +++ b/crates/engine/src/trim.rs @@ -208,7 +208,7 @@ fn trim_all_raw(s: &mut Session, delta_for: &dyn Fn(&Sequence, &EditPoint) -> Op let r = match ep.kind { TrimKind::Roll => { let Some((l, r)) = roll_pair(seq, ep) else { continue }; - s.execute("timeline.roll", json!({"left": l.0, "right": r.0, "delta": delta.0}))? + s.execute("timeline.roll", json!({"left": l.0, "right": r.0, "delta": delta.0, "followLinks": false}))? } k => s.execute( "timeline.trim", From 1d7288704514cd6475933cec502652ab22101991 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 20:24:21 -0400 Subject: [PATCH 257/260] tts-text: rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/tts-text/src/normalize.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/crates/tts-text/src/normalize.rs b/crates/tts-text/src/normalize.rs index 037d86334..b3bc2ceab 100644 --- a/crates/tts-text/src/normalize.rs +++ b/crates/tts-text/src/normalize.rs @@ -616,11 +616,7 @@ fn percent_suffix_len(rest: &str) -> Option { /// Retain digit-by-digit reading of zero-prefixed integer portions, even when /// followed by a percent sign or decimal fraction. fn spoken_integer(value: u64, digits: &str) -> Vec { - if has_leading_zero_str(digits) { - digits.chars().map(|c| english_digit_word(c).into()).collect() - } else { - english_cardinal(value) - } + if has_leading_zero_str(digits) { digits.chars().map(|c| english_digit_word(c).into()).collect() } else { english_cardinal(value) } } fn parse_continuation(rest: &str, used: usize, value: u64, digits: &str) -> Option<(Vec, usize)> { From a351076890447f9917d1633240dbb53f7241d981 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 20:37:07 -0400 Subject: [PATCH 258/260] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/tests/marked_playback_position.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/ui-egui/tests/marked_playback_position.rs b/crates/ui-egui/tests/marked_playback_position.rs index 1c78381d3..d30b50d13 100644 --- a/crates/ui-egui/tests/marked_playback_position.rs +++ b/crates/ui-egui/tests/marked_playback_position.rs @@ -14,10 +14,7 @@ fn program_range_playback_rests_on_inclusive_out_frame() { session.execute("markers.markIn", json!({"time": 0})).unwrap(); session.execute("markers.markOut", json!({"time": out.0})).unwrap(); - let mut harness = Harness::builder() - .with_size(egui::vec2(1280.0, 800.0)) - .with_step_dt(0.25) - .build_eframe(move |_cc| FilmcraftApp::new(session)); + let mut harness = Harness::builder().with_size(egui::vec2(1280.0, 800.0)).with_step_dt(0.25).build_eframe(move |_cc| FilmcraftApp::new(session)); harness.step(); let ctx = harness.ctx.clone(); From 2d1ffc14187c38bebb81c5a0557b432e74d0a9a0 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 20:56:45 -0400 Subject: [PATCH 259/260] masks_ui: Shift-clicks carry their modifiers in the gesture Since UI.6 (#881) control gestures set the modifiers of every frame they run, so the Shift that the test pushed by hand before plain ui.click calls was replaced and the #862 constraint test failed on main. The clicks now pass modifiers {shift: true}, like the drag already did. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/tests/masks_ui.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/tests/masks_ui.rs b/crates/ui-egui/tests/masks_ui.rs index 08934963f..4c04dbb49 100644 --- a/crates/ui-egui/tests/masks_ui.rs +++ b/crates/ui-egui/tests/masks_ui.rs @@ -232,18 +232,16 @@ fn shift_constrains_pen_mask_segments_to_45_degrees() { d.frames(2); let pic = d.rect("program.picture"); let at = |fx: f64, fy: f64| json!({"x": pic[0] + pic[2] * fx, "y": pic[1] + pic[3] * fy}); - // hold Shift (as the OS reports a held key) across the clicks - d.harness.input_mut().events.push(egui::Event::ModifiersChanged(egui::Modifiers::SHIFT)); - d.frames(1); + // Shift-clicks (control gestures carry their own modifiers since UI.6, #881) for (fx, fy) in [(0.3, 0.3), (0.6, 0.34), (0.63, 0.6)] { - d.ok("ui.click", at(fx, fy)); + let mut p = at(fx, fy); + p["modifiers"] = json!({"shift": true}); + d.ok("ui.click", p); d.frames(2); } // a Shift-drag places the fourth point and pulls out its handle d.ok("ui.drag", json!({"from": at(0.32, 0.62), "to": at(0.42, 0.64), "steps": 6, "modifiers": {"shift": true}})); d.frames(2); - d.harness.input_mut().events.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); - d.frames(1); d.ok("ui.click", at(0.3, 0.3)); d.frames(3); let m = d.masks(); From 383fafea8e414f1370cb475f87cbb584f8418982 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 20:56:58 -0400 Subject: [PATCH 260/260] export: rustfmt settings tests after merging #1113 Co-Authored-By: Claude Opus 5.5 --- crates/export/src/settings_tests.rs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/crates/export/src/settings_tests.rs b/crates/export/src/settings_tests.rs index 281e4b882..25b712308 100644 --- a/crates/export/src/settings_tests.rs +++ b/crates/export/src/settings_tests.rs @@ -868,12 +868,7 @@ fn tiff_size_estimate_tracks_alpha_and_real_output() { let duration = rate.tick_of(1); for alpha in [false, true] { let dir = Scratch::new(&format!("tiff-size-{alpha}")); - let mut settings = ExportSettings { - format: Format::TiffSequence, - path: dir.path("frame.tif"), - alpha, - ..Default::default() - }; + let mut settings = ExportSettings { format: Format::TiffSequence, path: dir.path("frame.tif"), alpha, ..Default::default() }; settings.range = Some(TimeRange::new(Tick::ZERO, duration)); settings.validate().unwrap();