From c733040a5cbfe539454086327d2878b0730e33fc Mon Sep 17 00:00:00 2001 From: Jeremy Yao Date: Sat, 10 Oct 2026 04:25:34 -0400 Subject: [PATCH 01/73] Windows: draw with the newest driver when one GPU is listed through two Vulkan drivers A PC whose GPUs were installed from different driver packages (an ASUS G513QY: the integrated Radeon on the 2021 package, the RX 6800M on the 2026 one, each with its own Vulkan ICD) lists the RX 6800M twice. wgpu's default takes the first discrete adapter, the stale ICD, and drawing through it dies with 0xc0000005 inside amdvlk64.dll before any Rust frame can catch it. apps/filmcraft/src/graphics.rs installs an egui-wgpu native_adapter_selector that keeps wgpu's ranking (device type for WGPU_POWER_PREF, then enumeration order) between different GPUs but, among entries that are one physical GPU (same backend, vendor and device id; bus id when both drivers report one), prefers the entry exposing the most wgpu features, i.e. the newer driver. Adapters that cannot draw to the window are skipped. With one driver per GPU the rule has nothing to merge and the choice is wgpu's own. WGPU_ADAPTER_NAME (part of the name, backend or driver text) picks an adapter by hand; candidates, duplicates and the choice are logged. The choice is a pure function over plain candidate data, with regression tests built from this machine's vulkaninfo listing (wgpu's rule picked the crashing entry 0; the selector picks entry 1) plus wgpu-order, surface, two-cards and override cases. Verified on the affected laptop: the release build now opens, answers the control channel, screenshots and quits (exit 0); it crashed on every launch before. Co-Authored-By: Claude Fable 5.1 --- README.md | 15 ++ apps/filmcraft/src/graphics.rs | 418 ++++++++++++++++++++++++++++++++- apps/filmcraft/src/main.rs | 3 +- 3 files changed, 428 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 8c498e3d..26f1cbe9 100644 --- a/README.md +++ b/README.md @@ -342,6 +342,21 @@ Remove-Item Env:WGPU_BACKEND # restore the default for later An explicit `gl` override can bring the driver crash back on affected systems. The macOS, Linux and web backend defaults are unchanged. +A PC whose GPUs were installed from different driver packages (a laptop with an integrated and a +discrete AMD GPU, each bringing its own Vulkan driver) lists one GPU twice, and drawing through the +stale entry crashes inside the driver (`amdvlk64.dll`, exit code `0xc0000005`) as the window opens. +FilmCraft draws with the entry that exposes the most features, the newer driver, and notes the +duplicate in `%APPDATA%\FilmCraft\Logs\filmcraft.log`; installing one driver package for all GPUs +removes it. Nothing is tied to one GPU or driver version: with one driver per GPU the choice is +wgpu's usual one. `WGPU_ADAPTER_NAME` (part of the adapter's name, backend or driver text as the log +writes them, any case) picks an adapter by hand: + +```powershell +$env:WGPU_ADAPTER_NAME = "Radeon(TM) Graphics" # draw with the integrated GPU +& .\filmcraft.exe +Remove-Item Env:WGPU_ADAPTER_NAME +``` + ### macOS | Build | File | Notes | diff --git a/apps/filmcraft/src/graphics.rs b/apps/filmcraft/src/graphics.rs index b2be99cb..31264d19 100644 --- a/apps/filmcraft/src/graphics.rs +++ b/apps/filmcraft/src/graphics.rs @@ -1,10 +1,36 @@ -//! Windows graphics instance configuration, applied before wgpu can initialize any driver. +//! Windows graphics set-up, applied before wgpu can initialize any driver: which backends the +//! instance creates, and which adapter the window draws with. +//! +//! **Backends.** eframe's default instance includes OpenGL; creating it can crash inside AMD's +//! `atio6axx.dll` before any Rust code runs, so the instance is limited to Vulkan and DirectX 12 +//! (see [`configure`]). +//! +//! **Adapter.** A machine with two driver packages for one GPU (a laptop whose integrated and +//! discrete AMD GPUs were installed from different driver versions, each with its own Vulkan ICD) +//! lists that GPU twice: once through each `amdvlk64.dll`. wgpu's default picks the first discrete +//! adapter, which is whichever ICD the loader found first; drawing through the stale one kills the +//! process with an access violation inside the driver (0xc0000005, no Rust frame to catch it). The +//! selector installed here keeps wgpu's order between different GPUs but, among entries that are the +//! same GPU, prefers the driver that exposes the most features: the newer one. Nothing here is tied +//! to a GPU, driver or machine: with one driver per GPU the rule has nothing to merge and the choice +//! is wgpu's own. `WGPU_ADAPTER_NAME` (a case-insensitive part of the adapter's name, backend or +//! driver as logged) picks an adapter by hand, as in wgpu's examples. -use eframe::{NativeOptions, egui_wgpu::WgpuSetup, wgpu::Backends}; +use std::cmp::Reverse; +use std::fmt; +use std::sync::Arc; + +use eframe::wgpu::{Adapter, Backend, Backends, DeviceType, PowerPreference, Surface}; +use eframe::{NativeOptions, egui_wgpu::WgpuSetup}; + +/// Environment variable naming the adapter to draw with: a case-insensitive part of its name, +/// backend (`Vulkan`, `Dx12`) or driver text, so that two entries of one GPU can be told apart. +/// Unset, empty or matching no adapter that can draw to the window: the automatic choice. +pub const ADAPTER_NAME_VAR: &str = "WGPU_ADAPTER_NAME"; /// 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. +/// names other backends; and choose the adapter with [`choose`]. 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 @@ -14,20 +40,212 @@ pub fn configure(options: &mut NativeOptions, backend_override: Option // 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); + // `WGPU_POWER_PREF` still applies: egui-wgpu read it into `power_preference`, which wgpu + // would ignore once a selector is set. + let power = create.power_preference; + let wanted = std::env::var(ADAPTER_NAME_VAR).ok().map(|s| s.trim().to_owned()).filter(|s| !s.is_empty()); + create.native_adapter_selector = + Some(Arc::new(move |adapters: &[Adapter], surface: Option<&Surface<'_>>| select(adapters, surface, power, wanted.as_deref()))); } } +/// One adapter's identity and capabilities, apart from the `wgpu::Adapter` so that the choice is a +/// pure function ([`choose`]) tests exercise without a GPU. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Candidate { + pub name: String, + pub backend: Backend, + pub device_type: DeviceType, + /// PCI vendor and device ids; both zero when the backend does not report them (OpenGL). + pub vendor: u32, + pub device: u32, + /// PCI bus id such as `0000:03:00.0`, or empty when the driver does not report one. + pub pci_bus_id: String, + /// Driver name and version, for the log. + pub driver: String, + /// How many wgpu features the driver exposes; a newer driver of the same GPU exposes more. + pub features: usize, + pub max_texture_dimension_2d: u32, + pub max_buffer_size: u64, + /// Whether the window surface can be drawn through this adapter (true without a surface). + pub draws_to_window: bool, +} + +impl Candidate { + fn describe(adapter: &Adapter, surface: Option<&Surface<'_>>) -> Candidate { + let info = adapter.get_info(); + let limits = adapter.limits(); + Candidate { + driver: format!("{} {}", info.driver, info.driver_info).trim().to_owned(), + name: info.name, + backend: info.backend, + device_type: info.device_type, + vendor: info.vendor, + device: info.device, + pci_bus_id: info.device_pci_bus_id, + features: adapter.features().iter().count(), + max_texture_dimension_2d: limits.max_texture_dimension_2d, + max_buffer_size: limits.max_buffer_size, + draws_to_window: surface.is_none_or(|s| adapter.is_surface_supported(s)), + } + } + + /// Whether `wanted` ([`ADAPTER_NAME_VAR`], already trimmed and lower-cased) names this adapter: + /// part of its name, backend or driver text. + fn named(&self, wanted: &str) -> bool { + [self.name.as_str(), &format!("{:?}", self.backend), self.driver.as_str()].iter().any(|text| text.to_lowercase().contains(wanted)) + } +} + +impl fmt::Display for Candidate { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let kind = match self.device_type { + DeviceType::DiscreteGpu => "discrete GPU", + DeviceType::IntegratedGpu => "integrated GPU", + DeviceType::VirtualGpu => "virtual GPU", + DeviceType::Cpu => "software", + DeviceType::Other => "unknown type", + }; + write!(f, "\"{}\" ({:?}, {kind}, {}, {} features", self.name, self.backend, self.driver, self.features)?; + if !self.pci_bus_id.is_empty() { + write!(f, ", bus {}", self.pci_bus_id)?; + } + if !self.draws_to_window { + write!(f, ", cannot draw to the window")?; + } + write!(f, ")") + } +} + +/// Whether two entries are one physical GPU seen through two drivers: same backend, PCI vendor and +/// device, and the same bus id when both drivers report one. Adapters without ids (OpenGL reports +/// zeros) are never duplicates: two of them cannot be told apart. +fn same_gpu(a: &Candidate, b: &Candidate) -> bool { + let identified = a.vendor != 0 || a.device != 0; + let same_bus = a.pci_bus_id.is_empty() || b.pci_bus_id.is_empty() || a.pci_bus_id == b.pci_bus_id; + identified && a.backend == b.backend && a.vendor == b.vendor && a.device == b.device && same_bus +} + +/// wgpu's own device-type preference for `power`: lower sorts first; `None` keeps enumeration order. +fn type_order(device_type: DeviceType, power: PowerPreference) -> u8 { + let prefer_integrated = match power { + PowerPreference::LowPower => true, + PowerPreference::HighPerformance => false, + PowerPreference::None => return 0, + }; + match device_type { + DeviceType::DiscreteGpu if prefer_integrated => 2, + DeviceType::IntegratedGpu if prefer_integrated => 1, + DeviceType::DiscreteGpu => 1, + DeviceType::IntegratedGpu => 2, + DeviceType::Other => 3, + DeviceType::VirtualGpu => 4, + DeviceType::Cpu => 5, + } +} + +/// Sort key of `candidate` (at `index` in `candidates`): wgpu's order (device type for `power`, +/// then enumeration order) between different GPUs, and among the entries of one GPU the driver +/// exposing the most features, then the highest limits. Entries of one GPU share the position of +/// the first of them, so a duplicate never jumps ahead of a different GPU wgpu would have preferred. +fn rank(candidates: &[Candidate], index: usize, candidate: &Candidate, power: PowerPreference) -> (u8, usize, Reverse<(usize, u32, u64)>, usize) { + let first_entry = candidates.iter().position(|other| same_gpu(candidate, other)).unwrap_or(index); + (type_order(candidate.device_type, power), first_entry, Reverse((candidate.features, candidate.max_texture_dimension_2d, candidate.max_buffer_size)), index) +} + +/// The index of the adapter to draw with, among those that can draw to the window: the one +/// `wanted_name` names (part of the name, backend or driver, case-insensitive) when any does, else the best by +/// [`rank`]. `None` when no adapter can draw to the window. +pub fn choose(candidates: &[Candidate], power: PowerPreference, wanted_name: Option<&str>) -> Option { + let best = |mut usable: Vec<(usize, &Candidate)>| { + usable.sort_by_key(|&(index, candidate)| rank(candidates, index, candidate, power)); + usable.first().map(|&(index, _)| index) + }; + let usable: Vec<(usize, &Candidate)> = candidates.iter().enumerate().filter(|(_, c)| c.draws_to_window).collect(); + if let Some(wanted) = wanted_name.map(str::trim).filter(|w| !w.is_empty()).map(str::to_lowercase) { + let named: Vec<(usize, &Candidate)> = usable.iter().copied().filter(|(_, c)| c.named(&wanted)).collect(); + if let Some(index) = best(named) { + return Some(index); + } + } + best(usable) +} + +/// One GPU listed more than once through one backend: one driver package per entry. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Duplicate { + pub backend: Backend, + pub name: String, + /// The driver of each entry, in enumeration order. + pub drivers: Vec, +} + +/// GPUs listed more than once (see [`same_gpu`]), in enumeration order. +pub fn duplicates(candidates: &[Candidate]) -> Vec { + let mut grouped = vec![false; candidates.len()]; + let mut out = Vec::new(); + for (i, candidate) in candidates.iter().enumerate() { + if grouped.get(i).copied().unwrap_or(true) { + continue; + } + let mut drivers = Vec::new(); + for (j, other) in candidates.iter().enumerate().skip(i) { + if same_gpu(candidate, other) { + drivers.push(other.driver.clone()); + if let Some(g) = grouped.get_mut(j) { + *g = true; + } + } + } + if drivers.len() > 1 { + out.push(Duplicate { backend: candidate.backend, name: candidate.name.clone(), drivers }); + } + } + out +} + +/// egui-wgpu's `native_adapter_selector`: [`choose`] over the enumerated adapters, with the +/// candidates, duplicates and the choice in the log (`/Logs/filmcraft.log`). +fn select(adapters: &[Adapter], surface: Option<&Surface<'_>>, power: PowerPreference, wanted_name: Option<&str>) -> Result { + let candidates: Vec = adapters.iter().map(|a| Candidate::describe(a, surface)).collect(); + for candidate in &candidates { + log::info!("graphics adapter available: {candidate}"); + } + for Duplicate { backend, name, drivers } in duplicates(&candidates) { + log::warn!( + "{backend:?} lists \"{name}\" {} times ({}): one GPU with several driver packages installed; \ + using the entry with the most features. Installing one driver package for every GPU removes the duplicate.", + drivers.len(), + drivers.join(" / ") + ); + } + let index = choose(&candidates, power, wanted_name).ok_or_else(|| { + let listed: Vec = candidates.iter().map(ToString::to_string).collect(); + format!("no graphics adapter can draw to the window (found {}: {})", listed.len(), listed.join(", ")) + })?; + let (Some(chosen), Some(adapter)) = (candidates.get(index), adapters.get(index)) else { + return Err(format!("graphics adapter {index} of {} disappeared while choosing", adapters.len())); + }; + if let Some(wanted) = wanted_name + && !chosen.named(&wanted.trim().to_lowercase()) + { + log::warn!("{ADAPTER_NAME_VAR}={wanted:?} names no adapter that can draw to the window; chosen automatically instead"); + } + log::info!("graphics adapter chosen: {chosen} ({power:?})"); + Ok(adapter.clone()) +} + #[cfg(test)] mod tests { use super::*; - fn configured_backends(backend_override: Option) -> Backends { + fn configured(backend_override: Option) -> eframe::egui_wgpu::WgpuSetupCreateNew { let mut options = NativeOptions::default(); configure(&mut options, backend_override); let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else { panic!("default native options must create a wgpu instance"); }; - create.instance_descriptor.backends + create } #[test] @@ -37,7 +255,7 @@ 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); + let configured = configured(None).instance_descriptor.backends; assert_eq!(configured, Backends::PRIMARY); assert!(!configured.contains(Backends::GL)); assert!(configured.contains(Backends::DX12) && configured.contains(Backends::VULKAN)); @@ -46,7 +264,193 @@ mod tests { #[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(Some(backends)).instance_descriptor.backends, backends); } } + + #[test] + fn the_adapter_selector_is_installed() { + assert!(configured(None).native_adapter_selector.is_some()); + assert!(configured(Some(Backends::DX12)).native_adapter_selector.is_some()); + } + + fn gpu(name: &str, backend: Backend, device_type: DeviceType, device: u32, bus: &str, driver: &str, features: usize) -> Candidate { + Candidate { + name: name.into(), + backend, + device_type, + vendor: 0x1002, + device, + pci_bus_id: bus.into(), + driver: driver.into(), + features, + max_texture_dimension_2d: 16384, + max_buffer_size: 1 << 32, + draws_to_window: true, + } + } + + const RX_6800M: u32 = 0x73df; + const RENOIR_IGPU: u32 = 0x1638; + + /// An ASUS G513QY as `vulkaninfo` lists it when the integrated and the discrete GPU were + /// installed from different driver packages: the RX 6800M through the 2021 ICD (Vulkan 1.2, + /// first), then through the 2026 ICD (Vulkan 1.4), then the integrated GPU; DirectX 12 after + /// Vulkan, as wgpu enumerates. wgpu's default (the first discrete adapter) is entry 0, and + /// drawing through it crashes inside the driver. + fn g513qy() -> Vec { + vec![ + gpu("AMD Radeon RX 6800M", Backend::Vulkan, DeviceType::DiscreteGpu, RX_6800M, "0000:03:00.0", "AMD proprietary driver 26.9.2", 49), + gpu( + "AMD Radeon RX 6800M", + Backend::Vulkan, + DeviceType::DiscreteGpu, + RX_6800M, + "0000:03:00.0", + "AMD proprietary driver 26.9.2 (AMD proprietary shader compiler)", + 63, + ), + gpu("AMD Radeon(TM) Graphics", Backend::Vulkan, DeviceType::IntegratedGpu, RENOIR_IGPU, "0000:08:00.0", "AMD proprietary driver 21.30.02.13", 47), + gpu("AMD Radeon RX 6800M", Backend::Dx12, DeviceType::DiscreteGpu, RX_6800M, "0000:03:00.0", "32.0.21045.11001", 52), + gpu("AMD Radeon(TM) Graphics", Backend::Dx12, DeviceType::IntegratedGpu, RENOIR_IGPU, "0000:08:00.0", "30.0.13002.13003", 50), + ] + } + + #[test] + fn one_gpu_listed_through_two_drivers_draws_with_the_driver_exposing_more_features() { + let adapters = g513qy(); + assert_eq!(choose(&adapters, PowerPreference::HighPerformance, None), Some(1)); + // The same list with the entries swapped still picks the richer driver. + let mut swapped = adapters.clone(); + swapped.swap(0, 1); + assert_eq!(choose(&swapped, PowerPreference::HighPerformance, None), Some(0)); + // Equal drivers: wgpu's order, the first entry. + let mut equal = adapters.clone(); + if let Some(c) = equal.get_mut(1) { + c.features = 49; + } + assert_eq!(choose(&equal, PowerPreference::HighPerformance, None), Some(0)); + // Equal features: the higher limits decide. + if let Some(c) = equal.get_mut(1) { + c.max_texture_dimension_2d = 32768; + } + assert_eq!(choose(&equal, PowerPreference::HighPerformance, None), Some(1)); + } + + #[test] + fn duplicates_are_reported_per_gpu() { + let dup = duplicates(&g513qy()); + assert_eq!(dup.len(), 1, "{dup:?}"); + assert_eq!((dup[0].backend, dup[0].name.as_str()), (Backend::Vulkan, "AMD Radeon RX 6800M")); + assert_eq!(dup[0].drivers.len(), 2); + assert!(dup[0].drivers[1].contains("shader compiler")); + assert!(duplicates(&g513qy()[2..]).is_empty()); + } + + #[test] + fn a_duplicate_never_overtakes_a_gpu_wgpu_would_prefer() { + // Low power: the integrated GPU comes first even though a discrete duplicate has more features. + assert_eq!(choose(&g513qy(), PowerPreference::LowPower, None), Some(2)); + // No preference: enumeration order between GPUs, the richer driver within the GPU. Rotated so the + // integrated GPU and the DirectX entries come first, the DirectX 6800M is the first discrete adapter + // and the Vulkan duplicates behind it stay behind it. + assert_eq!(choose(&g513qy(), PowerPreference::None, None), Some(1)); + let mut igpu_first = g513qy(); + igpu_first.rotate_left(2); + assert_eq!(choose(&igpu_first, PowerPreference::None, None), Some(0)); + assert_eq!(choose(&igpu_first, PowerPreference::HighPerformance, None), Some(1)); + } + + #[test] + fn without_duplicates_the_choice_is_wgpus() { + let adapters = vec![ + gpu("AMD Radeon(TM) Graphics", Backend::Vulkan, DeviceType::IntegratedGpu, RENOIR_IGPU, "", "x", 60), + gpu("AMD Radeon RX 6800M", Backend::Vulkan, DeviceType::DiscreteGpu, RX_6800M, "", "x", 40), + gpu("AMD Radeon RX 6800M", Backend::Dx12, DeviceType::DiscreteGpu, RX_6800M, "", "x", 70), + gpu("Microsoft Basic Render Driver", Backend::Dx12, DeviceType::Cpu, 0x8c, "", "x", 80), + ]; + assert_eq!(choose(&adapters, PowerPreference::HighPerformance, None), Some(1)); + assert_eq!(choose(&adapters, PowerPreference::LowPower, None), Some(0)); + assert_eq!(choose(&adapters, PowerPreference::None, None), Some(0)); + let software_only: Vec = adapters[3..].to_vec(); + assert_eq!(choose(&software_only, PowerPreference::HighPerformance, None), Some(0)); + } + + #[test] + fn two_cards_of_one_model_are_not_duplicates() { + let two_cards = vec![ + gpu("AMD Radeon RX 6800M", Backend::Vulkan, DeviceType::DiscreteGpu, RX_6800M, "0000:03:00.0", "x", 49), + gpu("AMD Radeon RX 6800M", Backend::Vulkan, DeviceType::DiscreteGpu, RX_6800M, "0000:04:00.0", "x", 63), + ]; + assert!(duplicates(&two_cards).is_empty()); + assert_eq!(choose(&two_cards, PowerPreference::HighPerformance, None), Some(0)); + // A driver that reports no bus id still counts as the same GPU as one that does. + let one_unreported = vec![ + gpu("AMD Radeon RX 6800M", Backend::Vulkan, DeviceType::DiscreteGpu, RX_6800M, "", "old", 49), + gpu("AMD Radeon RX 6800M", Backend::Vulkan, DeviceType::DiscreteGpu, RX_6800M, "0000:03:00.0", "new", 63), + ]; + assert_eq!(duplicates(&one_unreported).len(), 1); + assert_eq!(choose(&one_unreported, PowerPreference::HighPerformance, None), Some(1)); + // Adapters without PCI ids (OpenGL) cannot be told apart, so they are never merged. + let mut gl = two_cards.clone(); + for c in &mut gl { + c.backend = Backend::Gl; + c.vendor = 0; + c.device = 0; + c.pci_bus_id.clear(); + } + assert!(duplicates(&gl).is_empty()); + assert_eq!(choose(&gl, PowerPreference::HighPerformance, None), Some(0)); + } + + #[test] + fn adapters_that_cannot_draw_to_the_window_are_skipped() { + let mut adapters = g513qy(); + for c in adapters.iter_mut().filter(|c| c.backend == Backend::Vulkan) { + c.draws_to_window = false; + } + assert_eq!(choose(&adapters, PowerPreference::HighPerformance, None), Some(3)); + for c in &mut adapters { + c.draws_to_window = false; + } + assert_eq!(choose(&adapters, PowerPreference::HighPerformance, None), None); + assert_eq!(choose(&[], PowerPreference::HighPerformance, None), None); + } + + #[test] + fn an_adapter_named_in_the_environment_wins() { + let adapters = g513qy(); + assert_eq!(choose(&adapters, PowerPreference::HighPerformance, Some("radeon(tm) graphics")), Some(2)); + // Several entries match: the best of them by the usual rule. + assert_eq!(choose(&adapters, PowerPreference::HighPerformance, Some("6800M")), Some(1)); + assert_eq!(choose(&adapters, PowerPreference::LowPower, Some("AMD")), Some(2)); + // Two entries of one GPU share a name; the backend or the driver text tells them apart. + assert_eq!(choose(&adapters, PowerPreference::HighPerformance, Some("dx12")), Some(3)); + assert_eq!(choose(&adapters, PowerPreference::HighPerformance, Some("shader compiler")), Some(1)); + assert_eq!(choose(&adapters, PowerPreference::HighPerformance, Some("32.0.21045")), Some(3)); + let old_driver_only: Vec = adapters.iter().filter(|c| c.features != 63).cloned().collect(); + assert_eq!(choose(&old_driver_only, PowerPreference::HighPerformance, Some("Vulkan")), Some(0)); + // Blank or unknown names fall back to the automatic choice. + assert_eq!(choose(&adapters, PowerPreference::HighPerformance, Some(" ")), Some(1)); + assert_eq!(choose(&adapters, PowerPreference::HighPerformance, Some("GeForce")), Some(1)); + // A named adapter that cannot draw to the window is not chosen. + let mut no_igpu = adapters.clone(); + for c in no_igpu.iter_mut().filter(|c| c.device == RENOIR_IGPU) { + c.draws_to_window = false; + } + assert_eq!(choose(&no_igpu, PowerPreference::HighPerformance, Some("radeon(tm) graphics")), Some(1)); + } + + #[test] + fn candidates_describe_themselves_for_the_log() { + let adapters = g513qy(); + let text = adapters[1].to_string(); + assert!(text.starts_with("\"AMD Radeon RX 6800M\" (Vulkan, discrete GPU, AMD proprietary driver 26.9.2"), "{text}"); + assert!(text.contains("63 features") && text.contains("bus 0000:03:00.0") && text.ends_with(')'), "{text}"); + let mut offscreen = adapters[4].clone(); + offscreen.draws_to_window = false; + offscreen.pci_bus_id.clear(); + let text = offscreen.to_string(); + assert!(text.contains("integrated GPU") && text.contains("cannot draw to the window") && !text.contains("bus"), "{text}"); + } } diff --git a/apps/filmcraft/src/main.rs b/apps/filmcraft/src/main.rs index 18fb4d7b..c28c5405 100644 --- a/apps/filmcraft/src/main.rs +++ b/apps/filmcraft/src/main.rs @@ -129,7 +129,8 @@ fn main() -> eframe::Result { event_loop_builder: agent_event_loop(control_port.is_some()), ..Default::default() }; - // Before eframe creates the wgpu instance: leave OpenGL out on Windows (see graphics.rs). + // Before eframe creates the wgpu instance: leave OpenGL out on Windows, and draw with the + // right driver when a GPU is listed through two of them (see graphics.rs). #[cfg(target_os = "windows")] let options = { let mut options = options; From 3f5d4385dd8db10290c4b44fdb38bfec84d1791c Mon Sep 17 00:00:00 2001 From: liangforstudy <36067395+liangforstudy@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:44:07 +0800 Subject: [PATCH 02/73] Help menu: say where each item goes; keep the address when no browser opens Hovering a Help menu item now tells you what it does: the links (ArtCraft Discord, website, app page, GitHub, Report an Issue, FilmCraft Help) show "Opens in your web browser:
", and Reveal Log Files, System Compatibility Report and About FilmCraft say they stay in FilmCraft (no web page). When the host can't open a browser, the click was only logged. Now the address is copied to the clipboard and the status bar names the reason and the address. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/es.tsv | 5 +++ crates/ui-egui/src/i18n/ja.tsv | 5 +++ crates/ui-egui/src/i18n/zh-cn.tsv | 5 +++ crates/ui-egui/src/links.rs | 54 +++++++++++++++++++++++++++++-- crates/ui-egui/src/menus.rs | 6 +++- 5 files changed, 72 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 6119ccc9..6baf943b 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3586,3 +3586,8 @@ 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 + Opens in your web browser: {url} Se abre en el navegador web: {url} + Saves this session's log and shows it in your file manager (no web page). Guarda el registro de esta sesión y lo muestra en el explorador de archivos (no abre ninguna página web). + Shows a report on this computer in a FilmCraft window (no web page). Muestra un informe sobre este equipo en una ventana de FilmCraft (no abre ninguna página web). + Shows the version, community links and credits in a FilmCraft window (no web page). Muestra la versión, los enlaces de la comunidad y los créditos en una ventana de FilmCraft (no abre ninguna página web). + Couldn't open your web browser ({error}). The address is copied to the clipboard: {url} No se pudo abrir el navegador web ({error}). La dirección se ha copiado al portapapeles: {url} diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index d142b44b..e641321b 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3588,3 +3588,8 @@ Export the labels to a preset file… ラベルをプリセットファイルに書き出し… Show the presets folder プリセットフォルダーを表示 Delete this preset このプリセットを削除 + Opens in your web browser: {url} ウェブブラウザーで開きます:{url} + Saves this session's log and shows it in your file manager (no web page). このセッションのログを保存し、ファイルマネージャーで表示します(ウェブページは開きません)。 + Shows a report on this computer in a FilmCraft window (no web page). このコンピューターのレポートを FilmCraft のウィンドウに表示します(ウェブページは開きません)。 + Shows the version, community links and credits in a FilmCraft window (no web page). バージョン、コミュニティへのリンク、クレジットを FilmCraft のウィンドウに表示します(ウェブページは開きません)。 + Couldn't open your web browser ({error}). The address is copied to the clipboard: {url} ウェブブラウザーを開けませんでした({error})。アドレスをクリップボードにコピーしました:{url} diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index 15bc785f..423f2c07 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -3564,3 +3564,8 @@ Export the labels to a preset file… 将标签导出为预设文件… Show the presets folder 显示预设文件夹 Delete this preset 删除此预设 + Opens in your web browser: {url} 在网页浏览器中打开:{url} + Saves this session's log and shows it in your file manager (no web page). 保存本次会话的日志并在文件管理器中显示(不打开网页)。 + Shows a report on this computer in a FilmCraft window (no web page). 在 FilmCraft 窗口中显示本机的报告(不打开网页)。 + Shows the version, community links and credits in a FilmCraft window (no web page). 在 FilmCraft 窗口中显示版本、社区链接和致谢(不打开网页)。 + Couldn't open your web browser ({error}). The address is copied to the clipboard: {url} 无法打开网页浏览器({error})。地址已复制到剪贴板:{url} diff --git a/crates/ui-egui/src/links.rs b/crates/ui-egui/src/links.rs index 6d980e6c..5c4d257c 100644 --- a/crates/ui-egui/src/links.rs +++ b/crates/ui-egui/src/links.rs @@ -31,15 +31,39 @@ pub fn url_for(command: &str) -> Option<&'static str> { ALL.iter().find(|(id, _, _)| *id == command).map(|(_, _, u)| *u) } +/// What a Help menu item does, shown when the pointer rests on it: the address a link opens in +/// the web browser, or that the item stays inside FilmCraft. +pub fn menu_hint(command: &str) -> Option { + let url = if command == "help.filmcraftHelp" { Some(crate::panels::keyboard::HELP_URL) } else { url_for(command) }; + if let Some(url) = url { + return Some(tlf!("Opens in your web browser: {url}", url)); + } + Some( + match command { + "help.revealLogFiles" => tl!("Saves this session's log and shows it in your file manager (no web page)."), + "help.systemCompatibilityReport" => tl!("Shows a report on this computer in a FilmCraft window (no web page)."), + "app.about" => tl!("Shows the version, community links and credits in a FilmCraft window (no web page)."), + _ => return None, + } + .to_string(), + ) +} + /// 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). +/// backend is built without its `links` feature, so there it would only log (#642). When the +/// host can't open a browser, the address goes to the clipboard and the status bar says so, so +/// the click is never silently lost. 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}"), + Err(error) => { + log::warn!("can't open {url} in the browser: {error}"); + ctx.copy_text(url.to_string()); + app.ui.status = tlf!("Couldn't open your web browser ({error}). The address is copied to the clipboard: {url}", error, url); + } } } ctx.open_url(egui::OpenUrl::new_tab(url)); @@ -87,5 +111,31 @@ mod tests { 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]); + // ... and keeps the address at hand: copied, and named in the status bar with the reason + let copied: Vec = + ctx.output(|o| o.commands.iter().filter_map(|c| if let egui::OutputCommand::CopyText(t) = c { Some(t.clone()) } else { None }).collect()); + assert_eq!(copied, [ISSUES]); + assert!(app.ui.status.contains(ISSUES) && app.ui.status.contains("no browser"), "{}", app.ui.status); + } + + #[test] + fn help_menu_items_say_where_they_go() { + // links name their address; the other Help items say they stay inside FilmCraft + for (id, _, url) in ALL { + let hint = menu_hint(id).unwrap(); + assert!(hint.contains(url) && hint.contains("web browser"), "{id}: {hint}"); + } + assert!(menu_hint("help.filmcraftHelp").unwrap().contains(crate::panels::keyboard::HELP_URL)); + let help_items: Vec = crate::menus::menu_items(&crate::FilmcraftApp::new(filmcraft_engine::Session::default())) + .into_iter() + .filter(|it| it.path.first().map(String::as_str) == Some("Help")) + .map(|it| it.id) + .collect(); + assert!(help_items.len() >= ALL.len() + 3, "{help_items:?}"); + for id in &help_items { + let hint = menu_hint(id).unwrap_or_else(|| panic!("{id} has no hover hint")); + assert!(hint.contains("web browser") || hint.contains("no web page"), "{id}: {hint}"); + } + assert_eq!(menu_hint("edit.undo"), None); } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 77833a1b..04b2b891 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -931,7 +931,11 @@ fn menu_entry(ui: &mut egui::Ui, it: &MenuItem, auto: &mut crate::automation::Re if let Some(s) = &it.shortcut { b = b.shortcut_text(shortcut_text(s)); } - let r = ui.add_enabled(it.enabled, b); + let mut r = ui.add_enabled(it.enabled, b); + // Help items say whether they open the web browser (and which address) or stay in FilmCraft + if let Some(hint) = crate::links::menu_hint(&it.id) { + r = r.on_hover_text(hint); + } auto.add(&format!("menu.{}", it.id), r.rect, &it.label); if let Some(color) = swatch { paint_swatch(ui, r.rect, color); From aff5e7f1945ae358dd12a5d135d41a6e4ec6afb5 Mon Sep 17 00:00:00 2001 From: webdevtodayjason <115739170+webdevtodayjason@users.noreply.github.com> Date: Mon, 12 Oct 2026 03:04:27 +0000 Subject: [PATCH 03/73] Contributors: add webdevtodayjason (Jason Brashear) --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 04ed7243..e60240b3 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -77,3 +77,6 @@ sync_github_name = true real_name = "Valentin Klein" display_name = "Valentin Klein" sync_github_name = false + +[people.webdevtodayjason] +display_name = "Jason Brashear" From 518df2fb9232a667e5fc198eacc5ae5a3ef4c874 Mon Sep 17 00:00:00 2001 From: webdevtodayjason <115739170+webdevtodayjason@users.noreply.github.com> Date: Mon, 12 Oct 2026 03:04:27 +0000 Subject: [PATCH 04/73] Captions: preserve fractional TTML tick offsets --- crates/captions/src/ttml.rs | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/crates/captions/src/ttml.rs b/crates/captions/src/ttml.rs index c16d6976..bdb851b5 100644 --- a/crates/captions/src/ttml.rs +++ b/crates/captions/src/ttml.rs @@ -294,10 +294,7 @@ fn parse_time(s: &str, tm: Timing) -> Option { } whole.0 as i128 + decimal_ticks("0", vf, fd)? } - "t" => { - let n: i128 = vi.parse().ok()?; - n * tps / tm.tick_rate.max(1) as i128 - } + "t" => decimal_ticks(vi, vf, tps)? / tm.tick_rate.max(1) as i128, _ => return None, }; i64::try_from(t).ok().map(Tick) @@ -631,6 +628,23 @@ mod tests { assert!(!doc.warnings.is_empty()); } + #[test] + fn fractional_tick_offsets_keep_exact_caption_times() { + for format in [crate::Format::Ttml, crate::Format::Dfxp] { + let xml = r#"

Hello

"#; + let doc = crate::parse(xml.as_bytes(), format).unwrap(); + assert_eq!(doc.cues.len(), 1); + assert_eq!(doc.cues[0].start, Tick(TICKS_PER_SECOND * 3 / 20)); + assert_eq!(doc.cues[0].end, Tick(TICKS_PER_SECOND * 5 / 4)); + assert!(doc.warnings.is_empty()); + } + let timing = Timing { tick_rate: 3, ..tm(FrameRate::FPS_25) }; + assert_eq!(parse_time("0.5t", timing), Some(Tick(TICKS_PER_SECOND / 6))); + for expr in ["1.xt", "1.1234567890123t", "999999999999999.5t"] { + assert_eq!(parse_time(expr, timing), None, "{expr}"); + } + } + #[test] fn reads_styles_spans_and_nesting() { let x = r#" From d73196f3f72068a9200456454e275b1bda064a09 Mon Sep 17 00:00:00 2001 From: liangforstudy <36067395+liangforstudy@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:21:44 +0800 Subject: [PATCH 05/73] Help menu: a dialog keeps the address when no browser opens; shorter hints When the web browser can't be opened, a dialog now shows the reason and the address in a read-only field you can select from, with Copy, and stays until Close or Escape. The status bar message it replaces cleared itself after 8 seconds and claimed the address was on the clipboard, which egui can't confirm (a failed copy is only logged), so the address could be gone after switching to the browser window. The Help hints lose their "(no web page)" suffix, and System Compatibility Report's hint is just "Shows a report on this computer." Co-Authored-By: Claude Opus 5.5 --- 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/links.rs | 122 ++++++++++++++++++++++++--- crates/ui-egui/src/panels/dialogs.rs | 1 + crates/ui-egui/src/state.rs | 5 ++ 6 files changed, 129 insertions(+), 26 deletions(-) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 6baf943b..72a4e7c9 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3587,7 +3587,8 @@ Show the presets folder Mostrar la carpeta de ajustes preestablecidos Delete this preset Eliminar este ajuste preestablecido Opens in your web browser: {url} Se abre en el navegador web: {url} - Saves this session's log and shows it in your file manager (no web page). Guarda el registro de esta sesión y lo muestra en el explorador de archivos (no abre ninguna página web). - Shows a report on this computer in a FilmCraft window (no web page). Muestra un informe sobre este equipo en una ventana de FilmCraft (no abre ninguna página web). - Shows the version, community links and credits in a FilmCraft window (no web page). Muestra la versión, los enlaces de la comunidad y los créditos en una ventana de FilmCraft (no abre ninguna página web). - Couldn't open your web browser ({error}). The address is copied to the clipboard: {url} No se pudo abrir el navegador web ({error}). La dirección se ha copiado al portapapeles: {url} + Saves this session's log and shows it in your file manager. Guarda el registro de esta sesión y lo muestra en el explorador de archivos. + Shows a report on this computer. Muestra un informe sobre este equipo. + Shows the version, community links and credits in a FilmCraft window. Muestra la versión, los enlaces de la comunidad y los créditos en una ventana de FilmCraft. + Couldn't open your web browser No se pudo abrir el navegador web + Open this address in your browser yourself: Abre esta dirección tú mismo en el navegador: diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index e641321b..cfb00816 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3589,7 +3589,8 @@ Show the presets folder プリセットフォルダーを表示 Delete this preset このプリセットを削除 Opens in your web browser: {url} ウェブブラウザーで開きます:{url} - Saves this session's log and shows it in your file manager (no web page). このセッションのログを保存し、ファイルマネージャーで表示します(ウェブページは開きません)。 - Shows a report on this computer in a FilmCraft window (no web page). このコンピューターのレポートを FilmCraft のウィンドウに表示します(ウェブページは開きません)。 - Shows the version, community links and credits in a FilmCraft window (no web page). バージョン、コミュニティへのリンク、クレジットを FilmCraft のウィンドウに表示します(ウェブページは開きません)。 - Couldn't open your web browser ({error}). The address is copied to the clipboard: {url} ウェブブラウザーを開けませんでした({error})。アドレスをクリップボードにコピーしました:{url} + Saves this session's log and shows it in your file manager. このセッションのログを保存し、ファイルマネージャーで表示します。 + Shows a report on this computer. このコンピューターのレポートを表示します。 + Shows the version, community links and credits in a FilmCraft window. バージョン、コミュニティへのリンク、クレジットを FilmCraft のウィンドウに表示します。 + Couldn't open your web browser ウェブブラウザーを開けませんでした + Open this address in your browser yourself: このアドレスをご自身でブラウザーで開いてください: diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index 423f2c07..15c07adc 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -3565,7 +3565,8 @@ Show the presets folder 显示预设文件夹 Delete this preset 删除此预设 Opens in your web browser: {url} 在网页浏览器中打开:{url} - Saves this session's log and shows it in your file manager (no web page). 保存本次会话的日志并在文件管理器中显示(不打开网页)。 - Shows a report on this computer in a FilmCraft window (no web page). 在 FilmCraft 窗口中显示本机的报告(不打开网页)。 - Shows the version, community links and credits in a FilmCraft window (no web page). 在 FilmCraft 窗口中显示版本、社区链接和致谢(不打开网页)。 - Couldn't open your web browser ({error}). The address is copied to the clipboard: {url} 无法打开网页浏览器({error})。地址已复制到剪贴板:{url} + Saves this session's log and shows it in your file manager. 保存本次会话的日志并在文件管理器中显示。 + Shows a report on this computer. 显示本机的报告。 + Shows the version, community links and credits in a FilmCraft window. 在 FilmCraft 窗口中显示版本、社区链接和致谢。 + Couldn't open your web browser 无法打开网页浏览器 + Open this address in your browser yourself: 请自行在浏览器中打开此地址: diff --git a/crates/ui-egui/src/links.rs b/crates/ui-egui/src/links.rs index 5c4d257c..37df98da 100644 --- a/crates/ui-egui/src/links.rs +++ b/crates/ui-egui/src/links.rs @@ -26,6 +26,13 @@ pub const ALL: [(&str, &str, &str); 5] = [ ("help.reportIssue", "Report an Issue…", ISSUES), ]; +/// A link the web browser couldn't open (`ui.failedLink`): the address and the reason. +#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct FailedLink { + pub url: String, + pub error: String, +} + /// The URL a `help.*` link command opens. pub fn url_for(command: &str) -> Option<&'static str> { ALL.iter().find(|(id, _, _)| *id == command).map(|(_, _, u)| *u) @@ -40,9 +47,9 @@ pub fn menu_hint(command: &str) -> Option { } Some( match command { - "help.revealLogFiles" => tl!("Saves this session's log and shows it in your file manager (no web page)."), - "help.systemCompatibilityReport" => tl!("Shows a report on this computer in a FilmCraft window (no web page)."), - "app.about" => tl!("Shows the version, community links and credits in a FilmCraft window (no web page)."), + "help.revealLogFiles" => tl!("Saves this session's log and shows it in your file manager."), + "help.systemCompatibilityReport" => tl!("Shows a report on this computer."), + "app.about" => tl!("Shows the version, community links and credits in a FilmCraft window."), _ => return None, } .to_string(), @@ -53,22 +60,66 @@ pub fn menu_hint(command: &str) -> Option { /// [`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). When the -/// host can't open a browser, the address goes to the clipboard and the status bar says so, so -/// the click is never silently lost. +/// host can't open a browser, a dialog shows the address until it is closed ([`show_failed`]), +/// so the click is never silently lost: the clipboard can fail too, and egui only logs that. 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(error) => { log::warn!("can't open {url} in the browser: {error}"); - ctx.copy_text(url.to_string()); - app.ui.status = tlf!("Couldn't open your web browser ({error}). The address is copied to the clipboard: {url}", error, url); + app.ui.failed_link = Some(FailedLink { url: url.to_string(), error }); } } } ctx.open_url(egui::OpenUrl::new_tab(url)); } +/// The dialog for a link the browser couldn't open: the reason, and the address in a read-only +/// field you can select from, with Copy. It stays until Close or Escape, so the address is still +/// there after switching to another window. Automation ids: `failedLink.url`, `failedLink.copy`, +/// `failedLink.close`. +pub fn show_failed(app: &mut crate::FilmcraftApp, ctx: &egui::Context) { + let Some(link) = app.ui.failed_link.clone() else { return }; + let mut close = false; + let mut elems: Vec<(&str, egui::Rect, String)> = Vec::new(); + egui::Window::new(tl!("Couldn't open your web browser")) + .id(egui::Id::new("failed-link")) + .collapsible(false) + .resizable(false) + .pivot(egui::Align2::CENTER_CENTER) + .default_pos(ctx.content_rect().center()) + .show(ctx, |ui| { + ui.set_max_width(460.0); + if !link.error.is_empty() { + ui.label(egui::RichText::new(&link.error).small()); + } + ui.label(tl!("Open this address in your browser yourself:")); + ui.horizontal(|ui| { + let mut text = link.url.as_str(); + let r = ui.add(egui::TextEdit::singleline(&mut text).desired_width(340.0)); + elems.push(("failedLink.url", r.rect, link.url.clone())); + let c = ui.button(tl!("Copy")); + elems.push(("failedLink.copy", c.rect, "Copy".into())); + if c.clicked() { + ui.ctx().copy_text(link.url.clone()); + } + }); + ui.add_space(6.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let r = ui.button(tl!("Close")); + elems.push(("failedLink.close", r.rect, "Close".into())); + close |= r.clicked(); + }); + }); + for (id, r, label) in elems { + app.auto.add(id, r, &label); + } + if close || ctx.input(|i| i.key_pressed(egui::Key::Escape) && !i.pointer.any_down()) { + app.ui.failed_link = None; + } +} + #[cfg(test)] mod tests { use super::*; @@ -111,16 +162,58 @@ mod tests { 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]); - // ... and keeps the address at hand: copied, and named in the status bar with the reason - let copied: Vec = - ctx.output(|o| o.commands.iter().filter_map(|c| if let egui::OutputCommand::CopyText(t) = c { Some(t.clone()) } else { None }).collect()); - assert_eq!(copied, [ISSUES]); - assert!(app.ui.status.contains(ISSUES) && app.ui.status.contains("no browser"), "{}", app.ui.status); + // ... and keeps the address on screen with the reason until the dialog is closed + assert_eq!(app.ui.failed_link, Some(FailedLink { url: ISSUES.into(), error: "no browser".into() })); + } + + /// The failed-link dialog shows the address until it is closed, and Copy copies it. + #[test] + fn failed_link_dialog_keeps_the_address_until_closed() { + let mut app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); + app.hooks.open_url = Some(Box::new(|_: &str| Err("no browser".into()))); + let ctx = egui::Context::default(); + let time = std::cell::Cell::new(0.0); + let frame = |app: &mut crate::FilmcraftApp, events: Vec| { + app.auto.begin_frame(); + time.set(time.get() + 0.05); + let mut out = ctx.run_ui(egui::RawInput { time: Some(time.get()), events, ..Default::default() }, |ui| show_failed(app, ui.ctx())); + out.textures_delta.clear(); + out + }; + open(&mut app, &egui::Context::default(), GITHUB); + for _ in 0..3 { + frame(&mut app, Vec::new()); + } + assert!(app.auto.elements.iter().find(|e| e.id == "failedLink.url").is_some_and(|e| e.label == GITHUB), "address shown"); + let at = |app: &crate::FilmcraftApp, id: &str| { + app.auto.elements.iter().find(|e| e.id == id).map(|e| egui::pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0)).unwrap() + }; + // press and release on separate frames; returns what was copied + let click = |app: &mut crate::FilmcraftApp, p: egui::Pos2| { + let b = |pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let mut copied: Vec = Vec::new(); + for events in [vec![egui::Event::PointerMoved(p)], vec![b(true)], vec![b(false)], Vec::new()] { + let out = frame(app, events); + copied + .extend(out.platform_output.commands.iter().filter_map(|c| if let egui::OutputCommand::CopyText(t) = c { Some(t.clone()) } else { None })); + } + copied + }; + let copy = at(&app, "failedLink.copy"); + assert_eq!(click(&mut app, copy), [GITHUB]); + // other frames (another window in front) don't close it; Close does + for _ in 0..3 { + frame(&mut app, Vec::new()); + } + assert!(app.ui.failed_link.is_some()); + let close = at(&app, "failedLink.close"); + click(&mut app, close); + assert_eq!(app.ui.failed_link, None); } #[test] fn help_menu_items_say_where_they_go() { - // links name their address; the other Help items say they stay inside FilmCraft + // links name their address; the other Help items say what they do instead for (id, _, url) in ALL { let hint = menu_hint(id).unwrap(); assert!(hint.contains(url) && hint.contains("web browser"), "{id}: {hint}"); @@ -134,7 +227,8 @@ mod tests { assert!(help_items.len() >= ALL.len() + 3, "{help_items:?}"); for id in &help_items { let hint = menu_hint(id).unwrap_or_else(|| panic!("{id} has no hover hint")); - assert!(hint.contains("web browser") || hint.contains("no web page"), "{id}: {hint}"); + let link = url_for(id).is_some() || id == "help.filmcraftHelp"; + assert_eq!(hint.contains("web browser"), link, "{id}: {hint}"); } assert_eq!(menu_hint("edit.undo"), None); } diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index fa4f9407..f8dd2eaf 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -18,6 +18,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { crate::panels::workspaces::dialogs(app, ctx); crate::panels::voiceover::show(app, ctx); crate::panels::remix::show(app, ctx); + crate::links::show_failed(app, ctx); crate::panels::interchange_export::show(app, ctx); crate::panels::frame_export::show(app, ctx); crate::panels::project_dialogs::show(app, ctx); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index c6b7d40c..f24e5fa3 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -468,6 +468,10 @@ pub struct UiState { pub show_scopes: bool, /// Transient status line shown in the footer. pub status: String, + /// A link the web browser couldn't open, shown with its address until closed + /// ([`crate::links::show_failed`]). + #[serde(default)] + pub failed_link: Option, /// The colour parameter an armed eyedropper will fill with the next pixel clicked in the Program /// monitor (Esc or a click elsewhere disarms it). Never saved. #[serde(skip)] @@ -974,6 +978,7 @@ impl Default for UiState { dark: true, show_scopes: false, status: String::new(), + failed_link: None, eyedropper: None, keyframe_selection: Vec::new(), essential_sound_tab: "Edit".into(), From aa9d78cd9c99f6dc5a4fcfb4119b273488cc8bfa Mon Sep 17 00:00:00 2001 From: Ikki Nishihara Date: Mon, 12 Oct 2026 13:08:25 +0900 Subject: [PATCH 06/73] =?UTF-8?q?fix:=20=E6=96=B0=E8=A6=8F=E3=83=88?= =?UTF-8?q?=E3=83=A9=E3=83=83=E3=82=AF=E7=A7=BB=E5=8B=95=E3=81=AE=E3=83=AA?= =?UTF-8?q?=E3=83=B3=E3=82=AF=E3=81=A8=E7=B7=A8=E9=9B=86=E5=B1=A5=E6=AD=B4?= =?UTF-8?q?=E3=82=92=E4=BF=9D=E6=8C=81=20(#1187)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/engine/src/commands.rs | 60 ++++++++++-- crates/engine/src/sequence_tools.rs | 59 ------------ crates/engine/src/timeline_move_tests.rs | 113 +++++++++++++++++++++++ crates/ui-egui/src/panels/timeline.rs | 37 ++++++-- crates/ui-egui/tests/timeline_trim_ui.rs | 91 ++++++++++++++++++ 5 files changed, 282 insertions(+), 78 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index ce502fce..a1392b8c 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -345,8 +345,23 @@ pub(crate) fn item_p(p: &Value, k: &str) -> Option { fn move_clips(s: &mut Session, p: &Value) -> Result { const CMD: &str = "timeline.move"; let mut listed: Vec<(ClipId, TrackId, Tick)> = Vec::new(); + let mut new_moves = Vec::new(); for m in p.get("moves").and_then(Value::as_array).into_iter().flatten() { - let track = track_p(s, m, "track", CMD)?; + let track = if str_p(m, "track").is_some_and(|t| t.eq_ignore_ascii_case("new")) { + let clip = clip_p(m, "clip").ok_or_else(|| bad(CMD, "new track needs a clip"))?; + let q = s.active_sequence().ok_or(EngineError::NoSequence)?; + let (source, _) = q.find_item(clip).ok_or(edit::EditError::NoItem(clip))?; + if q.track(source).is_some_and(|t| t.locked) { + return Err(edit::EditError::Locked.into()); + } + if m.get("time").and_then(Value::as_i64).is_none() { + return Err(bad(CMD, "new track needs a time")); + } + new_moves.push((clip, source)); + Some(source) + } else { + track_p(s, m, "track", CMD)? + }; if let (Some(clip), Some(track), Some(time)) = (clip_p(m, "clip"), track, m.get("time").and_then(Value::as_i64)) { listed.push((clip, track, Tick(time))); } @@ -391,16 +406,48 @@ fn move_clips(s: &mut Session, p: &Value) -> Result { 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); + // Allocate destinations in the same transaction as the move: an invalid edit leaves + // neither empty tracks nor history entries. Each source track keeps its clips together. + let prepare = |q: &mut filmcraft_project::Sequence, ctx: &mut edit::EditCtx<'_>| -> Result> { + let sources: Vec<_> = q.all_tracks().filter(|t| new_moves.iter().any(|m| m.1 == t.id)).map(|t| (t.id, t.kind, t.channels)).collect(); + for kind in [TrackKind::Video, TrackKind::Audio] { + let added = sources.iter().filter(|s| s.1 == kind).count(); + if added > 0 && q.tracks(kind).len().saturating_add(added) > crate::sequence_tools::MAX_TRACKS { + return Err(bad(CMD, "new tracks exceed the sequence track limit")); + } + } + let mut destinations = Vec::new(); + for (source, kind, channels) in sources { + let tracks = if kind == TrackKind::Video { &mut q.video_tracks } else { &mut q.audio_tracks }; + let prefix = if kind == TrackKind::Video { "Video" } else { "Audio" }; + let id = TrackId(ctx.alloc()); + let mut track = Track::new(id, kind, format!("{prefix} {}", tracks.len() + 1)); + track.channels = channels; + tracks.push(track); + destinations.push((source, id)); + } + Ok(moves + .iter() + .map(|&(clip, track, time)| { + let dest = new_moves.iter().find(|m| m.0 == clip).and_then(|m| destinations.iter().find(|d| d.0 == m.1)).map_or(track, |d| d.1); + (clip, dest, time) + }) + .collect()) + }; 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)?; + let destinations = prepare(q, ctx)?; + let ids = edit::copy_items(q, &destinations, 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)?))?; + s.edit_sequence(if ins { "Move (Insert)" } else { "Move" }, |q, ctx, _| { + let destinations = prepare(q, ctx)?; + Ok(edit::move_items(q, &destinations, ins, ctx)?) + })?; Vec::new() }; // An overwrite shortens, splits or removes whatever already sits where a clip lands, and a @@ -2528,12 +2575,7 @@ fn build() -> Vec { None, r#"{"moves":[{"clip":id,"track":id|"V2"|"new" (after the last of its kind),"time":ticks}],"insert":bool,"linked":bool?,"copy":bool?}"#, has_seq, - |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) - } + move_clips ), cmd!( "timeline.trim", diff --git a/crates/engine/src/sequence_tools.rs b/crates/engine/src/sequence_tools.rs index f871f011..90716d2f 100644 --- a/crates/engine/src/sequence_tools.rs +++ b/crates/engine/src/sequence_tools.rs @@ -774,65 +774,6 @@ 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/timeline_move_tests.rs b/crates/engine/src/timeline_move_tests.rs index 565edd55..f6aa640f 100644 --- a/crates/engine/src/timeline_move_tests.rs +++ b/crates/engine/src/timeline_move_tests.rs @@ -299,3 +299,116 @@ fn copy_leaves_the_originals_and_links_the_copies() { assert!(r.is_err()); assert_eq!(s.active_sequence().unwrap().all_tracks().map(|t| t.items.len()).sum::(), 2); } + +#[test] +fn new_track_moves_and_copies_are_one_atomic_edit() { + for copy in [false, true] { + let mut s = demo(); + let (vt, v, at, a) = last_pair(&s); + let before = s.project.clone(); + let count = s.history.undo.len(); + let q = s.active_sequence().unwrap(); + let counts = (q.video_tracks.len(), q.audio_tracks.len()); + let r = s + .execute( + "timeline.move", + json!({"moves":[{"clip":v.id.0,"track":"new","time":v.start.0},{"clip":a.id.0,"track":"new","time":a.start.0}],"linked":false,"copy":copy}), + ) + .unwrap(); + let q = s.active_sequence().unwrap(); + assert_eq!((q.video_tracks.len(), q.audio_tracks.len()), (counts.0 + 1, counts.1 + 1)); + let new_v = q.video_tracks.last().unwrap(); + let new_a = q.audio_tracks.last().unwrap(); + assert_eq!(new_a.channels, before.sequence(s.state.active_sequence.unwrap()).unwrap().track(at).unwrap().channels); + assert_eq!((new_v.items[0].item, new_a.items[0].item), (v.item, a.item)); + assert_eq!((new_v.items[0].start, new_a.items[0].start), (v.start, a.start)); + if copy { + assert_eq!(state(&s, v.id), (vt, v.start)); + assert_eq!(state(&s, a.id), (at, a.start)); + assert_ne!(new_v.items[0].id, v.id); + assert!(new_v.items[0].link.is_some() && new_v.items[0].link == new_a.items[0].link); + assert_ne!(new_v.items[0].link, v.link); + assert_eq!(s.state.selection.len(), 2); + assert_eq!(r["copied"].as_array().unwrap().len(), 2); + } else { + assert_eq!(state(&s, v.id), (new_v.id, v.start)); + assert_eq!(state(&s, a.id), (new_a.id, a.start)); + } + q.check().unwrap(); + assert_eq!(s.history.undo.len(), count + 1); + let after = s.project.clone(); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(*s.project, *before); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(*s.project, *after); + } +} + +#[test] +fn new_track_group_keeps_clips_from_the_same_source_together() { + let mut s = demo(); + let q = s.active_sequence().unwrap(); + let clips = q.video_tracks[0].items[..2].to_vec(); + let tracks = q.video_tracks.len(); + let before = s.project.clone(); + let moves: Vec<_> = clips.iter().map(|c| json!({"clip":c.id.0,"track":"new","time":c.start.0})).collect(); + s.execute("timeline.move", json!({"moves":moves,"linked":false})).unwrap(); + let q = s.active_sequence().unwrap(); + assert_eq!(q.video_tracks.len(), tracks + 1); + let t = q.video_tracks.last().unwrap(); + assert_eq!(t.items, clips); + q.check().unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(*s.project, *before); +} + +#[test] +fn refused_new_track_moves_preserve_document_and_both_history_stacks() { + let mut s = demo(); + let (_, v, _, _) = last_pair(&s); + s.execute("timeline.setTrack", json!({"track":"V1","locked":true})).unwrap(); + for moves in [ + json!([{"clip":v.id.0,"track":"new","time":v.start.0}]), + json!([{"clip":u64::MAX,"track":"new","time":0}]), + json!([{"clip":v.id.0,"track":"new","time":i64::MAX}]), + json!([{"clip":v.id.0,"track":"new","time":0},{"clip":u64::MAX,"track":"V2","time":0}]), + ] { + let before = s.project.clone(); + let history = s.execute("history.list", json!({})).unwrap(); + assert!(s.execute("timeline.move", json!({"moves":moves,"linked":false})).is_err()); + assert_eq!(*s.project, *before); + assert_eq!(s.execute("history.list", json!({})).unwrap(), history); + } + s.execute("edit.undo", json!({})).unwrap(); + let before = s.project.clone(); + let history = s.execute("history.list", json!({})).unwrap(); + assert!(!history["redo"].as_array().unwrap().is_empty()); + for moves in [ + json!([{"clip":v.id.0,"track":"new","time":0},{"clip":u64::MAX,"track":"V2","time":0}]), + json!([{"clip":v.id.0,"track":"new","time":i64::MAX}]), + json!([{"clip":v.id.0,"track":"new"}]), + json!([{"clip":v.id.0,"track":"new","time":"invalid"}]), + ] { + assert!(s.execute("timeline.move", json!({"moves":moves,"linked":false})).is_err()); + assert_eq!(*s.project, *before); + assert_eq!(s.execute("history.list", json!({})).unwrap(), history); + } +} + +#[test] +fn new_track_moves_respect_the_track_limit() { + let mut s = demo(); + let (_, v, _, _) = last_pair(&s); + s.edit_sequence("Limit fixture", |q, ctx, _| { + while q.video_tracks.len() < crate::sequence_tools::MAX_TRACKS { + q.video_tracks.push(filmcraft_project::Track::new(TrackId(ctx.alloc()), filmcraft_project::TrackKind::Video, "Limit".into())); + } + Ok(()) + }) + .unwrap(); + let before = s.project.clone(); + let history = s.execute("history.list", json!({})).unwrap(); + assert!(s.execute("timeline.move", json!({"moves":[{"clip":v.id.0,"track":"new","time":v.start.0}],"linked":false})).is_err()); + assert_eq!(*s.project, *before); + assert_eq!(s.execute("history.list", json!({})).unwrap(), history); +} diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index ac4caf53..65fcc966 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -577,6 +577,25 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } } + // An existing clip dragged past the outer row previews its new destination (#1187). + if let Some(Drag::Move { clips, offset, new_track: Some((kind, band)), .. }) = app.tl.drag.clone() { + let preview = painter.with_clip_rect(band.intersect(content)); + for clip in clips { + if let Some((track, it)) = seq.find_item(clip) + && seq.track(track).is_some_and(|t| t.kind == kind) + { + let start = (it.start + offset).max(Tick::ZERO); + let body = Rect::from_min_max(pos2(layout.x_of(start), band.min.y + 1.0), pos2(layout.x_of(start + it.duration), band.max.y - 1.0)); + preview.rect_filled(body, 3.0, t.accent.gamma_multiply(0.4)); + preview.rect_stroke(body, 3.0, Stroke::new(1.5, t.accent), StrokeKind::Inside); + if body.height() >= 20.0 { + preview.text(body.left_top() + vec2(4.0, 2.0), Align2::LEFT_TOP, &it.name, Tokens::ui(11.0), t.text); + } + app.auto.add(&format!("timeline.newTrackPreview.{}", clip.0), body.intersect(band), &it.name); + } + } + } + // ---- track headers draw_headers(app, ui, seq, &rows, rect, vclip, aclip, &t); // divider between video and audio @@ -1784,6 +1803,7 @@ fn preview_drag(app: &FilmcraftApp, seq: &Sequence, out: &mut HashMap {} Drag::Move { clips, offset, track_delta, .. } => { for c in clips { if let Some((tid, it)) = seq.find_item(*c) { @@ -2456,16 +2476,13 @@ 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, }; - // #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); - } + // A linked or multitrack selection gets one destination per source track. + let new_track = start_row + .filter(|_| clips.iter().all(|c| seq.find_item(*c).is_some_and(|(tid, _)| seq.track(tid).is_some_and(|t| !t.locked)))) + .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) + }); 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)| { diff --git a/crates/ui-egui/tests/timeline_trim_ui.rs b/crates/ui-egui/tests/timeline_trim_ui.rs index c68d5612..23e12e85 100644 --- a/crates/ui-egui/tests/timeline_trim_ui.rs +++ b/crates/ui-egui/tests/timeline_trim_ui.rs @@ -390,3 +390,94 @@ fn dragging_in_the_trim_monitor_trims_without_hanging() { 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()); } + +fn move_existing_clip_to_a_new_track(audio: bool, linked: bool) { + let mut d = Driver::demo(); + d.exec("sequence.linkedSelection", json!({"on": linked})); + d.exec("sequence.snap", json!({"on": false})); + d.ok("ui.set", json!({"timeline":{"videoTrackHeight":32,"audioTrackHeight":32}})); + d.frames(6); + let q = d.app().session.active_sequence().unwrap(); + let tracks = if audio { &q.audio_tracks } else { &q.video_tracks }; + let count = tracks.len(); + let clip = tracks[0].items[0].id; + let original = q.find_item(clip).unwrap().1.clone(); + let other = if audio { &q.video_tracks } else { &q.audio_tracks }; + let other_count = other.len(); + let partner = linked.then(|| { + assert!(original.link.is_some()); + other.iter().flat_map(|t| &t.items).find(|i| i.link == original.link).unwrap().clone() + }); + let layout = d.app().tl.layout.as_ref().unwrap(); + let row = layout.rows.iter().find(|r| r.track == tracks[0].id).unwrap(); + let outer = layout.rows.iter().find(|r| r.track == tracks.last().unwrap().id).unwrap(); + let from = egui::pos2(layout.x_of(original.start + filmcraft_time::Tick(original.duration.0 / 2)), row.rect.min.y + 8.0); + let to = egui::pos2(from.x, if audio { outer.rect.max.y + 8.0 } else { outer.rect.min.y - 8.0 }); + assert!(layout.content.contains(to)); + assert!(layout.row_at(to.y).is_none()); + let before = d.app().session.project.clone(); + let undo = d.app().session.history.undo.len(); + d.harness.input_mut().events.push(egui::Event::PointerMoved(from)); + d.frames(1); + d.harness.input_mut().events.push(egui::Event::PointerButton { + pos: from, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: Default::default(), + }); + d.frames(1); + for i in 1..=12 { + d.harness.input_mut().events.push(egui::Event::PointerMoved(from + (to - from) * (i as f32 / 12.0))); + d.frames(1); + } + // A frame updates the gesture, then the next frame paints its destination. + d.frames(2); + let preview = d.ok("ui.elements", json!({"prefix":format!("timeline.newTrackPreview.{}",clip.0)})); + assert_eq!(preview.as_array().unwrap().len(), 1, "new destination is shown during the drag"); + assert_eq!(*d.app().session.project, *before); + assert_eq!(d.app().session.history.undo.len(), undo); + d.harness.input_mut().events.push(egui::Event::PointerButton { + pos: to, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: Default::default(), + }); + d.frames(2); + let q = d.app().session.active_sequence().unwrap(); + let tracks = if audio { &q.audio_tracks } else { &q.video_tracks }; + assert_eq!(tracks.len(), count + 1, "drop creates a new outer track"); + assert_eq!(q.find_item(clip).unwrap().0, tracks.last().unwrap().id); + assert_eq!(q.find_item(clip).unwrap().1, &original); + let other = if audio { &q.video_tracks } else { &q.audio_tracks }; + assert_eq!(other.len(), other_count + usize::from(linked)); + if let Some(partner) = partner { + assert_eq!(q.find_item(partner.id).unwrap().0, other.last().unwrap().id); + assert_eq!(q.find_item(partner.id).unwrap().1, &partner); + } + assert_eq!(d.app().session.history.undo.len(), undo + 1); + let after = d.app().session.project.clone(); + d.exec("edit.undo", json!({})); + assert_eq!(*d.app().session.project, *before); + d.exec("edit.redo", json!({})); + assert_eq!(*d.app().session.project, *after); +} + +#[test] +fn existing_video_clip_drag_above_tracks_adds_a_track() { + move_existing_clip_to_a_new_track(false, false); +} + +#[test] +fn existing_audio_clip_drag_below_tracks_adds_a_track() { + move_existing_clip_to_a_new_track(true, false); +} + +#[test] +fn linked_video_drag_adds_video_and_audio_tracks() { + move_existing_clip_to_a_new_track(false, true); +} + +#[test] +fn linked_audio_drag_adds_audio_and_video_tracks() { + move_existing_clip_to_a_new_track(true, true); +} From 6c13dda2bf289eafcf7d6b9fc5bf6ef1df1e1dfb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 05:42:44 +0000 Subject: [PATCH 07/73] export: 32-bit integer and 32-bit float audio; Sample Size lists every size and greys out the unsupported ones - AudioSettings gains `float`; `bits` takes 16, 24 or 32. `SampleFormat` (Int16 / Int24 / Int32 / Float32) and `ExportSettings::supports_sample_format` decide what each codec and container stores: WAV, AIFF and QuickTime PCM all four, MXF integers, FLAC integers. `validate` refuses float without 32 bits and float in FLAC or MXF. - WAV float is WAVE_FORMAT_IEEE_FLOAT (EXTENSIBLE for more than two channels) with a `fact` chunk; AIFF float is AIFF-C `fl32`; QuickTime float is `lpcm` with the float flag; MXF writes 32-bit integers. Float keeps the mix as it is, peaks above 0 dBFS included; 16 and 24 bit write the same bytes as before. - FLAC encoder: 8-32 bits. 32-bit stereo is coded independently (a side channel would need 33 bits), the sample range check no longer overflows, and 32-bit frames point at STREAMINFO for the size, which symphonia 0.5 (FilmCraft's own FLAC import) and ffmpeg both read. - Export mode: Sample Size shows 16 bit, 24 bit, 32 bit and 32 bit float like Audio Format does, greying out what the format cannot store with the reason on hover; a size that stops being available (float after switching to FLAC) becomes 32-bit integer. es, fr, pt-br, id, ja and zh-cn strings. - Tests: writer layouts and ffprobe codecs for every new format, round trips through ffmpeg and FilmCraft's own readers in WAV, AIFF, MOV, MXF and FLAC, headroom of a +6 dB tone, refused combinations, full-scale 32-bit FLAC through ffmpeg (MD5 too) and symphonia, and a UI test of the selector. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015nzevSsTNZxHPpUngfGTAw --- crates/export/src/job.rs | 26 ++- crates/export/src/lib.rs | 30 ++- crates/export/src/mxf_out.rs | 3 +- crates/export/src/pcm.rs | 249 +++++++++++++++++------ crates/export/src/settings.rs | 111 +++++++++- crates/export/src/settings_tests.rs | 199 ++++++++++++++++++ crates/flac/README.md | 9 +- crates/flac/src/frame.rs | 2 + crates/flac/src/lib.rs | 17 +- crates/flac/tests/ffmpeg_oracle.rs | 36 +++- crates/ui-egui/src/i18n/es.tsv | 4 + crates/ui-egui/src/i18n/fr.tsv | 4 + crates/ui-egui/src/i18n/id.tsv | 4 + crates/ui-egui/src/i18n/ja.tsv | 4 + crates/ui-egui/src/i18n/pt-br.tsv | 4 + crates/ui-egui/src/i18n/zh-cn.tsv | 4 + crates/ui-egui/src/panels/export_mode.rs | 39 +++- crates/ui-egui/tests/export_ui.rs | 69 +++++++ docs/architecture.md | 8 +- 19 files changed, 698 insertions(+), 124 deletions(-) diff --git a/crates/export/src/job.rs b/crates/export/src/job.rs index 5c26ea52..fe339700 100644 --- a/crates/export/src/job.rs +++ b/crates/export/src/job.rs @@ -363,18 +363,9 @@ impl Exporter { } } None => { - let bps = if self.settings.audio.bits >= 24 { 3 } else { 2 }; - let mut pcm = Vec::with_capacity(n * buf.len() * bps); - for i in 0..n { - for c in &buf { - let s = c[i].clamp(-1.0, 1.0); - if bps == 3 { - pcm.extend_from_slice(&((s * 8_388_607.0).round() as i32).to_le_bytes()[..3]); - } else { - pcm.extend_from_slice(&((s * 32767.0).round() as i16).to_le_bytes()); - } - } - } + // little-endian, like the track's `lpcm` / `sowt` description says + let mut pcm = Vec::new(); + crate::pcm::encode(crate::pcm::PcmContainer::Wav, &crate::audio_out::interleave(&buf), self.settings.audio.sample_format(), &mut pcm); mux.write_sample(at, WriteSample { data: &pcm, duration: n as u32, composition_offset: 0, is_sync: true }) .map_err(|e| ExportError::Io(e.to_string()))?; } @@ -428,8 +419,15 @@ impl Exporter { Some(mux.add_track(c).map_err(|e| ExportError::Io(e.to_string()))?) } (Some(a), None) => { - let bits = if self.settings.audio.bits >= 24 { 24 } else { 16 }; - let pcm = PcmConfig { bits, float: false, big_endian: false, signed: true, channels: a.channels as _, sample_rate: a.sr as f64 }; + let f = self.settings.audio.sample_format(); + let pcm = PcmConfig { + bits: f.bits() as _, + float: f.is_float(), + big_endian: false, + signed: true, + channels: a.channels as _, + sample_rate: a.sr as f64, + }; Some(mux.add_track(TrackConfig::new(SampleEntry::pcm(pcm), a.sr)).map_err(|e| ExportError::Io(e.to_string()))?) } }; diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 66905c99..ebea2550 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -625,6 +625,14 @@ impl ExportSettings { if self.audio.sample_rate.is_some_and(|rate| rate == 0 || rate > 384_000) { return Err(ExportError::Unsupported("output sample rate must be between 1 and 384000 Hz".into())); } + if self.audio.float && self.audio.bits != 32 { + return Err(ExportError::Unsupported("floating-point audio is 32-bit: set the sample size to 32".into())); + } + let sample_format = self.audio.sample_format(); + if (self.has_audio() || self.format.is_audio_only()) && self.audio_codec() != AudioCodec::Aac && !self.supports_sample_format(sample_format) { + let codec = if self.audio_codec() == AudioCodec::Flac { "FLAC" } else { self.format.label() }; + return Err(ExportError::Unsupported(format!("{codec} audio has no {} samples: choose 16, 24 or 32 bit", sample_format.label()))); + } if self.field_order != FieldOrder::Progressive && self.has_video() { return Err(ExportError::Unsupported(format!("{} field order: FilmCraft's encoders write progressive frames", self.field_order.label()))); } @@ -1108,19 +1116,19 @@ impl AudioEncoder for AacEncoder { /// FLAC (our encoder) in MP4 / QuickTime: one frame of 4096 samples per access unit. pub(crate) struct FlacAudio { enc: filmcraft_flac::Encoder, - bits: u16, + format: SampleFormat, /// Samples of the frame `flush` returns (shorter than a full block). tail: u32, } impl FlacAudio { pub(crate) fn new(sample_rate: u32, channels: u32, s: &ExportSettings) -> Result { - let bits = if s.audio.bits >= 24 { 24 } else { 16 }; + let format = s.audio.sample_format(); let ch = u8::try_from(channels).map_err(|_| ExportError::Unsupported("FLAC: too many audio channels".into()))?; - let mut cfg = filmcraft_flac::EncoderConfig::new(sample_rate, ch, bits as u8); + let mut cfg = filmcraft_flac::EncoderConfig::new(sample_rate, ch, format.bits() as u8); cfg.level = s.audio.flac_level.min(8); let enc = filmcraft_flac::Encoder::new(cfg).map_err(|e| ExportError::Unsupported(format!("FLAC: {e}")))?; - Ok(FlacAudio { enc, bits, tail: 0 }) + Ok(FlacAudio { enc, format, tail: 0 }) } } @@ -1129,7 +1137,7 @@ impl AudioEncoder for FlacAudio { SampleEntry::flac(&self.enc.streaminfo()) } fn encode(&mut self, planar: &[Vec]) -> Result>> { - let ints: Vec> = planar.iter().map(|c| c.iter().map(|&s| pcm::quantise(s, self.bits)).collect()).collect(); + let ints: Vec> = planar.iter().map(|c| c.iter().map(|&s| self.format.quantise(s)).collect()).collect(); let refs: Vec<&[i32]> = ints.iter().map(Vec::as_slice).collect(); self.enc.encode_frames(&refs).map_err(|e| ExportError::Encode(format!("FLAC: {e}"))) } @@ -1851,12 +1859,12 @@ pub fn export(project: &Arc, seq: ItemId, settings: &ExportSettings, so Format::Aac => aac_out::export(project, seq, settings, range, sources, progress)?, Format::Wav | Format::Aiff | Format::Flac => { 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 (ch, sr, fmt) = (a.channels as u16, a.sr, settings.audio.sample_format()); let frames = a.remaining(); // the sizes are known up front: refuse an AIFF that cannot hold them before measuring let mut writer = match settings.format { Format::Flac => { - let mut cfg = filmcraft_flac::EncoderConfig::new(sr, ch as u8, if bits >= 24 { 24 } else { 16 }); + let mut cfg = filmcraft_flac::EncoderConfig::new(sr, ch as u8, fmt.bits() as u8); cfg.level = settings.audio.flac_level.min(8); AudioWriter::Flac(Box::new(filmcraft_flac::Encoder::new(cfg).map_err(|e| ExportError::Unsupported(format!("FLAC: {e}")))?)) } @@ -1864,7 +1872,7 @@ pub fn export(project: &Arc, seq: ItemId, settings: &ExportSettings, so _ => AudioWriter::Pcm(pcm::PcmContainer::Aiff), }; let head = match &writer { - AudioWriter::Pcm(c) => pcm::header(*c, ch, sr, bits, frames).map_err(ExportError::Unsupported)?, + AudioWriter::Pcm(c) => pcm::header(*c, ch, sr, fmt, frames).map_err(ExportError::Unsupported)?, AudioWriter::Flac(enc) => enc.header(), }; // one second per chunk: memory stays flat however long the range is @@ -1885,9 +1893,9 @@ pub fn export(project: &Arc, seq: ItemId, settings: &ExportSettings, so } buf.clear(); match &mut writer { - AudioWriter::Pcm(c) => pcm::encode(*c, &audio_out::interleave(&planar), bits, &mut buf), + AudioWriter::Pcm(c) => pcm::encode(*c, &audio_out::interleave(&planar), fmt, &mut buf), AudioWriter::Flac(enc) => { - let ints: Vec> = planar.iter().map(|c| c.iter().map(|&s| pcm::quantise(s, bits)).collect()).collect(); + let ints: Vec> = planar.iter().map(|c| c.iter().map(|&s| fmt.quantise(s)).collect()).collect(); let refs: Vec<&[i32]> = ints.iter().map(Vec::as_slice).collect(); buf = enc.encode(&refs).map_err(|e| ExportError::Encode(format!("FLAC: {e}")))?; } @@ -1898,7 +1906,7 @@ pub fn export(project: &Arc, seq: ItemId, settings: &ExportSettings, so } } match &mut writer { - AudioWriter::Pcm(_) => out.write_all(pcm::pad(frames, ch, bits)).map_err(io)?, + AudioWriter::Pcm(_) => out.write_all(pcm::pad(frames, ch, fmt)).map_err(io)?, AudioWriter::Flac(enc) => { out.write_all(&enc.finish()).map_err(io)?; // the frame sizes and MD5 are known now: fill them into STREAMINFO diff --git a/crates/export/src/mxf_out.rs b/crates/export/src/mxf_out.rs index ecd8ede2..a7adabb7 100644 --- a/crates/export/src/mxf_out.rs +++ b/crates/export/src/mxf_out.rs @@ -108,7 +108,8 @@ impl MxfMux { let now = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0); let seed = format!("{}|{now}", settings.path); let ids = PackageIds::from_seed(&seed, &s.name); - let bits = if settings.audio.bits >= 24 { 24 } else { 16 }; + // integers only: `ExportSettings::validate` refuses float PCM in MXF + let bits = u32::from(settings.audio.sample_format().bits()); let mut cfg = WriterConfig::new(if atom { Pattern::OpAtom } else { Pattern::Op1a }, rate, ids.clone()); cfg.picture = Some(picture(settings, s.width, s.height)); cfg.modified = Timestamp::from_unix((now / 1_000_000_000) as i64); diff --git a/crates/export/src/pcm.rs b/crates/export/src/pcm.rs index 7601886f..96f1300f 100644 --- a/crates/export/src/pcm.rs +++ b/crates/export/src/pcm.rs @@ -1,10 +1,13 @@ //! 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. +//! 16, 24 or 32-bit integer or 32-bit float ([`SampleFormat`]), plus image-sequence file naming. +//! Float WAV is `WAVE_FORMAT_IEEE_FLOAT` with a `fact` chunk; float AIFF is AIFF-C (`fl32`). //! //! 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. +use crate::SampleFormat; + /// The container of an audio-only export. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum PcmContainer { @@ -12,47 +15,55 @@ pub enum PcmContainer { Aiff, } -/// Quantise one sample to a signed integer of `bits` (16 or 24). -pub(crate) fn quantise(s: f32, bits: u16) -> i32 { - let max = if bits >= 24 { 8_388_607.0 } else { 32_767.0 }; - (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)) +fn data_len(frames: u64, channels: u16, format: SampleFormat) -> Option { + frames.checked_mul(u64::from(channels))?.checked_mul(format.bytes() as u64) } /// 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 { &[] } +pub fn pad(frames: u64, channels: u16, format: SampleFormat) -> &'static [u8] { + if data_len(frames, channels, format).is_some_and(|n| n & 1 == 1) { &[0] } else { &[] } } +/// `KSDATAFORMAT_SUBTYPE_PCM` / `_IEEE_FLOAT` after their first two bytes (the format tag). +const SUBTYPE_TAIL: [u8; 14] = [0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71]; + +/// AIFF-C format version 1 (`FVER`). +const AIFC_VERSION_1: u32 = 0xA280_5140; + +/// The AIFF-C compression name of `fl32`, as a Pascal string of even length. +const FL32_NAME: &[u8] = b"\x1532-bit floating point"; + /// 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> { +pub fn header(container: PcmContainer, channels: u16, sample_rate: u32, format: SampleFormat, frames: u64) -> Result, String> { let too_long = || "the audio is too long to write".to_string(); - let bits = sample_bits(bits); + let bits = format.bits(); let bps = bits / 8; - let data = data_len(frames, channels, bits).ok_or_else(too_long)?; + let float = format.is_float(); + let data = data_len(frames, channels, format).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 }; + // plain PCM has no cbSize; IEEE float has an empty one + let fmt_len: u32 = if ext { + 40 + } else if float { + 18 + } else { + 16 + }; + // every format but integer PCM has a `fact` chunk (the frame count; RF64: in `ds64`) + let fact_len: u64 = if float { 12 } else { 0 }; 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)?; + // "WAVE" + fmt chunk + fact chunk + data chunk header + samples + let riff = (4 + 8 + u64::from(fmt_len) + fact_len + 8).checked_add(padded).ok_or_else(too_long)?; let rf64 = riff > u64::from(u32::MAX); - let mut v = Vec::with_capacity(80); + let mut v = Vec::with_capacity(100); if rf64 { const DS64: u64 = 8 + 28; v.extend_from_slice(b"RF64"); @@ -68,21 +79,30 @@ pub fn header(container: PcmContainer, channels: u16, sample_rate: u32, bits: u1 v.extend_from_slice(&(riff as u32).to_le_bytes()); v.extend_from_slice(b"WAVE"); } + let tag: u16 = if float { 3 } else { 1 }; 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(&(if ext { 0xFFFE } else { tag }).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 + // cbSize, valid bits, channel mask, KSDATAFORMAT_SUBTYPE_PCM / _IEEE_FLOAT 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(&tag.to_le_bytes()); + v.extend_from_slice(&SUBTYPE_TAIL); + } else if float { + v.extend_from_slice(&0u16.to_le_bytes()); + } + if float { + v.extend_from_slice(b"fact"); + v.extend_from_slice(&4u32.to_le_bytes()); + v.extend_from_slice(&(if rf64 { u32::MAX } else { u32::try_from(frames).unwrap_or(u32::MAX) }).to_le_bytes()); } v.extend_from_slice(b"data"); v.extend_from_slice(&(if rf64 { u32::MAX } else { data as u32 }).to_le_bytes()); @@ -93,16 +113,31 @@ pub fn header(container: PcmContainer, channels: u16, sample_rate: u32, bits: u1 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); + // floats need AIFF-C: a version chunk, and the compression type and name in COMM + let comm: u32 = if float { 18 + 4 + FL32_NAME.len() as u32 } else { 18 }; + let fver: u32 = if float { 8 + 4 } else { 0 }; + let form = + (4u32 + fver + (8 + comm)).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(90); 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()); + if float { + v.extend_from_slice(b"AIFCFVER"); + v.extend_from_slice(&4u32.to_be_bytes()); + v.extend_from_slice(&AIFC_VERSION_1.to_be_bytes()); + } else { + v.extend_from_slice(b"AIFF"); + } + v.extend_from_slice(b"COMM"); + v.extend_from_slice(&comm.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)); + if float { + v.extend_from_slice(b"fl32"); + v.extend_from_slice(FL32_NAME); + } v.extend_from_slice(b"SSND"); v.extend_from_slice(&ssnd.to_be_bytes()); v.extend_from_slice(&0u32.to_be_bytes()); // offset @@ -112,13 +147,12 @@ pub fn header(container: PcmContainer, channels: u16, sample_rate: u32, bits: u1 } } -/// 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); +/// Append interleaved samples in `format` (little-endian for WAV, big-endian for AIFF). +pub fn encode(container: PcmContainer, interleaved: &[f32], format: SampleFormat, out: &mut Vec) { + let bps = format.bytes(); out.reserve(interleaved.len().saturating_mul(bps)); for &s in interleaved { - let q = quantise(s, bits); + let q = if format.is_float() { SampleFormat::float(s).to_bits() as i32 } else { format.quantise(s) }; 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()), @@ -127,19 +161,19 @@ pub fn encode(container: PcmContainer, interleaved: &[f32], bits: u16, out: &mut } /// 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> { +fn write_whole(container: PcmContainer, interleaved: &[f32], channels: u16, sample_rate: u32, format: SampleFormat) -> 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)); + let mut v = header(container, channels, sample_rate, format, frames)?; + encode(container, interleaved, format, &mut v); + v.extend_from_slice(pad(frames, channels, format)); 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) -> Result, String> { - write_whole(PcmContainer::Wav, interleaved, channels, sample_rate, bits) +pub fn write_wav(interleaved: &[f32], channels: u16, sample_rate: u32, format: SampleFormat) -> Result, String> { + write_whole(PcmContainer::Wav, interleaved, channels, sample_rate, format) } /// IEEE 754 80-bit extended encoding of a sample rate (AIFF `COMM`). @@ -156,9 +190,9 @@ fn extended(rate: u32) -> [u8; 10] { out } -/// An AIFF file (`FORM`/`AIFF`, `COMM` + `SSND`) of interleaved samples. -pub fn write_aiff(interleaved: &[f32], channels: u16, sample_rate: u32, bits: u16) -> Result, String> { - write_whole(PcmContainer::Aiff, interleaved, channels, sample_rate, bits) +/// An AIFF file (`FORM`/`AIFF`, `COMM` + `SSND`; AIFF-C for floats) of interleaved samples. +pub fn write_aiff(interleaved: &[f32], channels: u16, sample_rate: u32, format: SampleFormat) -> Result, String> { + write_whole(PcmContainer::Aiff, interleaved, channels, sample_rate, format) } /// File name of frame `index` of an image sequence of `count` frames exported to `path`: @@ -181,6 +215,11 @@ pub fn image_sequence_path(path: &str, index: u64, count: u64) -> String { mod tests { use super::*; + const I16: SampleFormat = SampleFormat::Int16; + const I24: SampleFormat = SampleFormat::Int24; + const I32: SampleFormat = SampleFormat::Int32; + const F32: SampleFormat = SampleFormat::Float32; + #[test] fn extended_sample_rates() { assert_eq!(extended(44_100), [0x40, 0x0E, 0xAC, 0x44, 0, 0, 0, 0, 0, 0]); @@ -198,10 +237,10 @@ 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).unwrap(); + let w = write_wav(&s, 2, 48_000, I24).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).unwrap(); + let a = write_aiff(&s, 2, 48_000, I16).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()); @@ -220,7 +259,7 @@ mod tests { #[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 h = header(PcmContainer::Wav, 6, 96_000, I24, frames).unwrap(); let data = frames * 6 * 3; assert_eq!(&h[..4], b"RF64"); assert_eq!(le32(&h, 4), u32::MAX); @@ -238,37 +277,129 @@ mod tests { 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(); + let h = header(PcmContainer::Wav, 2, 48_000, I16, 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"); + assert_eq!(&header(PcmContainer::Wav, 2, 48_000, I16, 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(); + let e = header(PcmContainer::Aiff, 2, 48_000, I24, 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()); + assert!(header(PcmContainer::Aiff, 2, 48_000, I24, 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(c, 2, 48_000, I24, u64::MAX).is_err()); + assert!(header(c, u16::MAX, 48_000, I24, 1).is_err()); } - assert!(header(PcmContainer::Wav, 6, u32::MAX, 24, 1).is_err()); + assert!(header(PcmContainer::Wav, 6, u32::MAX, I24, 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(); + let w = write_wav(&[0.25], 1, 48_000, I24).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(); + let a = write_aiff(&[0.25], 1, 48_000, I24).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()); + assert!(pad(2, 1, I24).is_empty()); + } + + fn be32(b: &[u8], at: usize) -> u32 { + u32::from_be_bytes(b[at..at + 4].try_into().unwrap()) + } + + #[test] + fn integer_samples_clip_at_full_scale() { + assert_eq!([1.0, -1.0, 2.0, -2.0, f32::NAN, 0.5].map(|s| I32.quantise(s)), [i32::MAX, -i32::MAX, i32::MAX, -i32::MAX, 0, 1 << 30]); + assert_eq!([1.0, -1.0, 4.0].map(|s| I24.quantise(s)), [8_388_607, -8_388_607, 8_388_607]); + assert_eq!([1.0, -1.0, f32::INFINITY].map(|s| I16.quantise(s)), [32_767, -32_767, 32_767]); + assert_eq!(F32.quantise(0.5), 0); + assert_eq!([2.5, f32::NAN, f32::INFINITY, f32::NEG_INFINITY].map(SampleFormat::float), [2.5, 0.0, f32::MAX, f32::MIN]); + } + + #[test] + fn wav_and_aiff_32_bit_integer() { + let w = write_wav(&[1.0, -1.0], 1, 48_000, I32).unwrap(); + assert_eq!(w.len(), 44 + 8); + assert_eq!(u16::from_le_bytes([w[20], w[21]]), 1, "integer PCM"); + assert_eq!((u16::from_le_bytes([w[32], w[33]]), u16::from_le_bytes([w[34], w[35]])), (4, 32)); + assert_eq!(&w[44..], [i32::MAX.to_le_bytes(), (-i32::MAX).to_le_bytes()].concat()); + let a = write_aiff(&[1.0, -1.0], 1, 48_000, I32).unwrap(); + assert_eq!(&a[8..12], b"AIFF"); + assert_eq!(u16::from_be_bytes([a[26], a[27]]), 32); + assert_eq!(&a[a.len() - 8..], [i32::MAX.to_be_bytes(), (-i32::MAX).to_be_bytes()].concat()); + } + + /// Float keeps the mix as it is: peaks above full scale survive. + #[test] + fn wav_32_bit_float() { + let s = [0.25f32, -2.0, 1.5, f32::NAN]; + let w = write_wav(&s, 2, 48_000, F32).unwrap(); + assert_eq!(&w[12..16], b"fmt "); + assert_eq!(le32(&w, 16), 18); + assert_eq!(u16::from_le_bytes([w[20], w[21]]), 3, "WAVE_FORMAT_IEEE_FLOAT"); + assert_eq!((u16::from_le_bytes([w[32], w[33]]), u16::from_le_bytes([w[34], w[35]]), u16::from_le_bytes([w[36], w[37]])), (8, 32, 0)); + assert_eq!((&w[38..42], le32(&w, 42), le32(&w, 46)), (&b"fact"[..], 4, 2)); + assert_eq!((&w[50..54], le32(&w, 54)), (&b"data"[..], 16)); + assert_eq!(le32(&w, 4) as usize, w.len() - 8); + let back: Vec = w[58..].chunks(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect(); + assert_eq!(back, [0.25, -2.0, 1.5, 0.0]); + // 5.1: WAVE_FORMAT_EXTENSIBLE with the IEEE float sub-format, still with `fact` + let w = write_wav(&[0.5; 12], 6, 48_000, F32).unwrap(); + assert_eq!((le32(&w, 16), u16::from_le_bytes([w[20], w[21]])), (40, 0xFFFE)); + assert_eq!(&w[44..60], [&3u16.to_le_bytes()[..], &SUBTYPE_TAIL].concat()); + assert_eq!((&w[60..64], le32(&w, 68)), (&b"fact"[..], 2)); + // RF64: the frame count is in `ds64` + let h = header(PcmContainer::Wav, 2, 48_000, F32, 48_000 * 60 * 60 * 4).unwrap(); + assert_eq!(&h[..4], b"RF64"); + let fact = h.windows(4).position(|c| c == b"fact").unwrap(); + assert_eq!(le32(&h, fact + 8), u32::MAX); + } + + #[test] + fn aiff_32_bit_float_is_aifc() { + let a = write_aiff(&[0.25, -3.0], 1, 44_100, F32).unwrap(); + assert_eq!((&a[..4], be32(&a, 4) as usize, &a[8..12]), (&b"FORM"[..], a.len() - 8, &b"AIFC"[..])); + assert_eq!((&a[12..16], be32(&a, 16), be32(&a, 20)), (&b"FVER"[..], 4, AIFC_VERSION_1)); + assert_eq!((&a[24..28], be32(&a, 28)), (&b"COMM"[..], 44)); + assert_eq!((u16::from_be_bytes([a[32], a[33]]), be32(&a, 34), u16::from_be_bytes([a[38], a[39]])), (1, 2, 32)); + assert_eq!(&a[50..54], b"fl32"); + assert_eq!(&a[54..76], FL32_NAME); + assert_eq!((&a[76..80], be32(&a, 80)), (&b"SSND"[..], 8 + 8)); + assert_eq!(&a[92..], [0.25f32.to_be_bytes(), (-3.0f32).to_be_bytes()].concat()); + } + + /// ffprobe (an external oracle; skipped where it is not installed) reads every new format. + #[test] + fn ffprobe_reads_32_bit_formats() { + let dir = std::env::temp_dir().join(format!("filmcraft-pcm32-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let s: Vec = (0..9_600).map(|i| (i as f32 * 0.01).sin() * 1.5).collect(); + let cases = [ + ("i32.wav", write_wav(&s, 2, 48_000, I32).unwrap(), "pcm_s32le"), + ("f32.wav", write_wav(&s, 2, 48_000, F32).unwrap(), "pcm_f32le"), + ("f32-6.wav", write_wav(&s, 6, 48_000, F32).unwrap(), "pcm_f32le"), + ("i32.aiff", write_aiff(&s, 2, 48_000, I32).unwrap(), "pcm_s32be"), + ("f32.aifc", write_aiff(&s, 2, 48_000, F32).unwrap(), "pcm_f32be"), + ]; + for (name, bytes, codec) in cases { + let path = dir.join(name); + std::fs::write(&path, bytes).unwrap(); + let out = std::process::Command::new("ffprobe") + .args(["-v", "error", "-select_streams", "a:0", "-show_entries", "stream=codec_name,bits_per_sample", "-of", "default=nw=1"]) + .arg(&path) + .output(); + let Ok(out) = out else { break }; + let text = String::from_utf8_lossy(&out.stdout); + assert!(text.contains(&format!("codec_name={codec}")) && text.contains("bits_per_sample=32"), "{name}: {text}"); + } + let _ = std::fs::remove_dir_all(&dir); } /// A reader takes the RF64 sizes: ffprobe reports the duration from `ds64` (skipped without it). @@ -278,8 +409,8 @@ mod tests { 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); + let mut f = header(PcmContainer::Wav, 2, 48_000, I24, frames).unwrap(); + encode(PcmContainer::Wav, &[0.0; 9_600], I24, &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"]) diff --git a/crates/export/src/settings.rs b/crates/export/src/settings.rs index a59f481e..ab954adc 100644 --- a/crates/export/src/settings.rs +++ b/crates/export/src/settings.rs @@ -223,15 +223,96 @@ pub struct AudioSettings { pub channels: u32, /// AAC bitrate. pub bitrate_kbps: u32, - /// PCM and FLAC sample size: 16 or 24. + /// PCM and FLAC sample size: 16, 24 or 32 (other values round down to one of them, 16 at least). pub bits: u16, + /// 32-bit floating-point PCM instead of integers (needs `bits` 32; WAV, AIFF and QuickTime). + pub float: bool, /// FLAC compression level, 0 (fastest) to 8 (smallest). Every level is lossless. pub flac_level: u8, } impl Default for AudioSettings { fn default() -> Self { - AudioSettings { codec: AudioCodec::Auto, sample_rate: None, channels: 2, bitrate_kbps: 320, bits: 16, flac_level: 5 } + AudioSettings { codec: AudioCodec::Auto, sample_rate: None, channels: 2, bitrate_kbps: 320, bits: 16, float: false, flac_level: 5 } + } +} + +impl AudioSettings { + /// The sample format [`AudioSettings::bits`] and [`AudioSettings::float`] ask for. + pub fn sample_format(&self) -> SampleFormat { + match self.bits { + 32.. if self.float => SampleFormat::Float32, + 32.. => SampleFormat::Int32, + 24.. => SampleFormat::Int24, + _ => SampleFormat::Int16, + } + } + + /// Ask for `f` ([`AudioSettings::bits`] and [`AudioSettings::float`]). + pub fn set_sample_format(&mut self, f: SampleFormat) { + self.bits = f.bits(); + self.float = f.is_float(); + } +} + +/// How PCM and FLAC audio stores its samples. The mix is 32-bit float: the integer formats +/// clip it at full scale, 32-bit float keeps it as it is (peaks above 0 dBFS included). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SampleFormat { + Int16, + Int24, + Int32, + Float32, +} + +impl SampleFormat { + pub const ALL: [SampleFormat; 4] = [SampleFormat::Int16, SampleFormat::Int24, SampleFormat::Int32, SampleFormat::Float32]; + + pub fn bits(self) -> u16 { + match self { + SampleFormat::Int16 => 16, + SampleFormat::Int24 => 24, + SampleFormat::Int32 | SampleFormat::Float32 => 32, + } + } + + /// Bytes per sample. + pub fn bytes(self) -> usize { + usize::from(self.bits() / 8) + } + + pub fn is_float(self) -> bool { + self == SampleFormat::Float32 + } + + /// The name the Export settings show. + pub fn label(self) -> &'static str { + match self { + SampleFormat::Int16 => "16 bit", + SampleFormat::Int24 => "24 bit", + SampleFormat::Int32 => "32 bit", + SampleFormat::Float32 => "32 bit float", + } + } + + /// One mix sample as a signed integer of this size, clipped at full scale (0 for + /// [`SampleFormat::Float32`], which is never quantised). Not-a-number becomes silence. + pub fn quantise(self, s: f32) -> i32 { + // f32 products for 16 and 24 bit (the bytes every earlier export wrote); f64 for 32 bit, + // whose full scale 2^31 - 1 f32 cannot hold. NaN casts to 0, the casts saturate. + let s = s.clamp(-1.0, 1.0); + match self { + SampleFormat::Int16 => (s * 32_767.0).round() as i32, + SampleFormat::Int24 => (s * 8_388_607.0).round() as i32, + SampleFormat::Int32 => (f64::from(s) * 2_147_483_647.0).round() as i32, + SampleFormat::Float32 => 0, + } + } + + /// One mix sample as stored by [`SampleFormat::Float32`]: as it is, NaN as silence and + /// infinities as the largest finite values. + pub fn float(s: f32) -> f32 { + if s.is_nan() { 0.0 } else { s.clamp(f32::MIN, f32::MAX) } } } @@ -473,6 +554,16 @@ impl ExportSettings { Resolved { width: w, height: h, rate, sample_rate: self.audio.sample_rate.unwrap_or(seq_sr), channels, target_kbps: target, max_kbps: max, keyint } } + /// Whether the audio codec and container of this export store samples as `f`: PCM in WAV, + /// AIFF and QuickTime takes all four, MXF integers only; FLAC integers only; AAC none. + pub fn supports_sample_format(&self, f: SampleFormat) -> bool { + match self.audio_codec() { + AudioCodec::Aac => false, + AudioCodec::Flac => !f.is_float(), + AudioCodec::Pcm | AudioCodec::Auto => !(f.is_float() && self.format.is_mxf()), + } + } + /// The audio codec actually used. pub fn audio_codec(&self) -> AudioCodec { match (self.format, self.audio.codec) { @@ -548,7 +639,7 @@ impl ExportSettings { Format::Wav | Format::Aiff | Format::Flac | Format::MxfOp1a | Format::MxfOpAtom => 0.0, }; let audio_bps = if self.has_audio() || self.format.is_audio_only() { - let pcm = r.sample_rate as f64 * r.channels as f64 * if self.audio.bits >= 24 { 24.0 } else { 16.0 }; + let pcm = r.sample_rate as f64 * r.channels as f64 * f64::from(self.audio.sample_format().bits()); match self.audio_codec() { AudioCodec::Aac => self.audio.bitrate_kbps as f64 * 1000.0, // typical of music and dialogue; noise compresses less, silence far more @@ -649,13 +740,13 @@ impl ExportSettings { }; match self.audio_codec() { AudioCodec::Aac => format!("AAC, {} kbps, {} Hz, {ch}", self.audio.bitrate_kbps, r.sample_rate), - AudioCodec::Flac => format!( - "FLAC (lossless) {}-bit, level {}, {} Hz, {ch}", - if self.audio.bits >= 24 { 24 } else { 16 }, - self.audio.flac_level.min(8), - r.sample_rate - ), - _ => format!("Uncompressed {}-bit PCM, {} Hz, {ch}", if self.audio.bits >= 24 { 24 } else { 16 }, r.sample_rate), + AudioCodec::Flac => { + format!("FLAC (lossless) {}-bit, level {}, {} Hz, {ch}", self.audio.sample_format().bits(), self.audio.flac_level.min(8), r.sample_rate) + } + _ => { + let f = self.audio.sample_format(); + format!("Uncompressed {}-bit{} PCM, {} Hz, {ch}", f.bits(), if f.is_float() { " float" } else { "" }, r.sample_rate) + } } } else { "No audio".to_string() diff --git a/crates/export/src/settings_tests.rs b/crates/export/src/settings_tests.rs index b2ada981..d0b7a86a 100644 --- a/crates/export/src/settings_tests.rs +++ b/crates/export/src/settings_tests.rs @@ -1135,3 +1135,202 @@ fn loudness_normalization_corrects_limiter_loss_and_bounds_impossible_targets() assert_eq!(chunks.get(), 3); assert_eq!(reads_at_cancel.get(), Some(reads.load(Ordering::Relaxed))); } + +/// 32-bit integer and float audio in every container that takes them, read back by ffprobe (an +/// external oracle) and, where FilmCraft imports the format, by FilmCraft's own readers. +#[test] +fn thirty_two_bit_audio_in_every_container() { + let (p, seq, m) = matte([0.0, 0.0, 0.0, 1.0], 64, 36, Some(-6.0)); + let dir = Scratch::new("pcm32"); + let range = Some(TimeRange::new(Tick::ZERO, Tick(TICKS_PER_SECOND / 2))); + let cases: [(Format, SampleFormat, &str, &str, &str); 8] = [ + (Format::Wav, SampleFormat::Float32, "f.wav", "pcm_f32le", "32"), + (Format::Wav, SampleFormat::Int32, "i.wav", "pcm_s32le", "32"), + (Format::Aiff, SampleFormat::Float32, "f.aif", "pcm_f32be", "32"), + (Format::Aiff, SampleFormat::Int32, "i.aif", "pcm_s32be", "32"), + (Format::ProRes, SampleFormat::Float32, "f.mov", "pcm_f32le", "32"), + (Format::ProRes, SampleFormat::Int32, "i.mov", "pcm_s32le", "32"), + (Format::MxfOp1a, SampleFormat::Int32, "i.mxf", "pcm_s32le", "32"), + (Format::Flac, SampleFormat::Int32, "i.flac", "flac", "32"), + ]; + for (format, sf, name, codec, bits) in cases { + let path = dir.path(name); + let mut s = ExportSettings { format, path: path.clone(), range, ..Default::default() }; + s.audio.set_sample_format(sf); + assert!(s.supports_sample_format(sf), "{name}"); + export(&p, seq, &s, &m, &Progress::default()).unwrap_or_else(|e| panic!("{name}: {e}")); + let bytes = std::fs::read(&path).unwrap(); + // FilmCraft's own importer: the -6 dB tone at a peak of 0.5 + if !matches!(format, Format::MxfOp1a) { + let src = filmcraft_codecs::open_bytes(name, bytes.into()).unwrap_or_else(|e| panic!("{name}: {e}")); + let a = src.audio(0, 24_000, 48_000).unwrap(); + let peak = a.channels[0].iter().fold(0.0f32, |m, v| m.max(v.abs())); + assert!((peak - 0.5).abs() < 0.01, "{name}: peak {peak}"); + } + if let Some(j) = ffprobe_json(&["-show_streams"], &path) { + let st = j["streams"].as_array().unwrap().iter().find(|s| s["codec_type"] == "audio").unwrap().clone(); + assert_eq!(st["codec_name"].as_str(), Some(codec), "{name}"); + let depth = if codec == "flac" { &st["bits_per_raw_sample"] } else { &st["bits_per_sample"] }; + assert_eq!(depth.as_str().map(str::to_string).or_else(|| depth.as_u64().map(|v| v.to_string())).as_deref(), Some(bits), "{name}"); + } + } + // every 32-bit file holds the same samples (float exactly, integers within one step of 2^-31) + if let Some(ffmpeg) = filmcraft_testkit::ffmpeg_or_skip("32-bit audio") { + let raw = |name: &str| { + let out = std::process::Command::new(&ffmpeg) + .args(["-v", "error", "-i", &dir.path(name), "-map", "0:a", "-f", "f32le", "-c:a", "pcm_f32le", "-"]) + .output() + .unwrap(); + assert!(out.status.success(), "{name}: {}", String::from_utf8_lossy(&out.stderr)); + out.stdout.chunks_exact(4).map(|b| f32::from_le_bytes(b.try_into().unwrap())).collect::>() + }; + let reference = raw("f.wav"); + assert_eq!(reference.len(), 24_000 * 2); + for name in ["f.aif", "f.mov"] { + assert!(raw(name) == reference, "{name}: float samples differ from the WAV's"); + } + for name in ["i.wav", "i.aif", "i.mov", "i.mxf", "i.flac"] { + let got = raw(name); + assert_eq!(got.len(), reference.len(), "{name}"); + let worst = got.iter().zip(&reference).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max); + assert!(worst < 1e-6, "{name}: differs by {worst}"); + } + } +} + +/// The mix is 32-bit float: 32-bit float export keeps a +6 dB tone's peaks of 2.0, integers clip +/// them at full scale. +#[test] +fn float_export_keeps_peaks_above_full_scale() { + let (p, seq, m) = matte([0.0, 0.0, 0.0, 1.0], 64, 36, Some(6.0)); + let dir = Scratch::new("pcm-hot"); + let range = Some(TimeRange::new(Tick::ZERO, Tick(TICKS_PER_SECOND / 4))); + let peak = |sf: SampleFormat, name: &str| { + let path = dir.path(name); + let mut s = ExportSettings { format: Format::Wav, path: path.clone(), range, ..Default::default() }; + s.audio.set_sample_format(sf); + export(&p, seq, &s, &m, &Progress::default()).unwrap(); + let src = filmcraft_codecs::open_bytes(name, std::fs::read(&path).unwrap().into()).unwrap(); + src.audio(0, 12_000, 48_000).unwrap().channels[0].iter().fold(0.0f32, |m, v| m.max(v.abs())) + }; + let float = peak(SampleFormat::Float32, "f.wav"); + assert!((float - 2.0).abs() < 0.02, "float keeps +6 dBFS: {float}"); + for (sf, name) in [(SampleFormat::Int32, "i32.wav"), (SampleFormat::Int24, "i24.wav"), (SampleFormat::Int16, "i16.wav")] { + let p = peak(sf, name); + assert!(p <= 1.0 && p > 0.99, "{name} clips at full scale: {p}"); + } +} + +#[test] +fn sample_formats_each_codec_and_container_stores() { + use SampleFormat::*; + let supported = |format: Format, codec: AudioCodec| { + let s = ExportSettings { format, audio: AudioSettings { codec, ..Default::default() }, ..Default::default() }; + SampleFormat::ALL.iter().filter(|&&f| s.supports_sample_format(f)).copied().collect::>() + }; + assert_eq!(supported(Format::Wav, AudioCodec::Auto), [Int16, Int24, Int32, Float32]); + assert_eq!(supported(Format::Aiff, AudioCodec::Auto), [Int16, Int24, Int32, Float32]); + assert_eq!(supported(Format::ProRes, AudioCodec::Pcm), [Int16, Int24, Int32, Float32]); + assert_eq!(supported(Format::MxfOp1a, AudioCodec::Auto), [Int16, Int24, Int32]); + assert_eq!(supported(Format::MxfOpAtom, AudioCodec::Auto), [Int16, Int24, Int32]); + assert_eq!(supported(Format::Flac, AudioCodec::Auto), [Int16, Int24, Int32]); + assert_eq!(supported(Format::ProRes, AudioCodec::Flac), [Int16, Int24, Int32]); + assert!(supported(Format::H264, AudioCodec::Auto).is_empty(), "MP4 is AAC"); + // `bits` rounds down to a size; `float` only with 32 + let fmt = |bits, float| AudioSettings { bits, float, ..Default::default() }.sample_format(); + assert_eq!( + [fmt(0, false), fmt(20, false), fmt(24, false), fmt(31, false), fmt(32, false), fmt(64, false), fmt(32, true)], + [Int16, Int16, Int24, Int24, Int32, Int32, Float32] + ); +} + +/// Hostile or contradictory settings (CLI, control channel, MCP, old presets) are errors, never +/// a panic or a silently different file. +#[test] +fn unsupported_sample_formats_are_refused() { + let refused = |format: Format, codec: AudioCodec, bits: u16, float: bool| { + let s = ExportSettings { format, path: "x".into(), audio: AudioSettings { codec, bits, float, ..Default::default() }, ..Default::default() }; + s.validate().err().map(|e| e.to_string()) + }; + assert!(refused(Format::Wav, AudioCodec::Auto, 24, true).is_some_and(|e| e.contains("32"))); + assert!(refused(Format::Flac, AudioCodec::Auto, 32, true).is_some_and(|e| e.contains("FLAC"))); + assert!(refused(Format::ProRes, AudioCodec::Flac, 32, true).is_some_and(|e| e.contains("FLAC"))); + assert!(refused(Format::MxfOp1a, AudioCodec::Auto, 32, true).is_some_and(|e| e.contains("MXF"))); + for (format, codec) in [(Format::Wav, AudioCodec::Auto), (Format::Aiff, AudioCodec::Auto), (Format::ProRes, AudioCodec::Pcm)] { + assert_eq!(refused(format, codec, 32, true), None, "{format:?}"); + } + assert_eq!(refused(Format::Flac, AudioCodec::Auto, 32, false), None); + assert_eq!(refused(Format::MxfOp1a, AudioCodec::Auto, 32, false), None); + // AAC has no sample size: the PCM / FLAC choice is ignored, as before + assert_eq!(refused(Format::H264, AudioCodec::Auto, 32, true), None); + // no audio, nothing to refuse + let s = ExportSettings { + format: Format::MxfOp1a, + include_audio: false, + audio: AudioSettings { bits: 32, float: true, ..Default::default() }, + ..Default::default() + }; + assert!(s.validate().is_ok()); +} + +#[test] +fn sample_format_settings_round_trip_summary_and_estimate() { + let s: ExportSettings = serde_json::from_value(serde_json::json!({"format": "wav", "audio": {"bits": 32, "float": true}})).unwrap(); + assert_eq!(s.audio.sample_format(), SampleFormat::Float32); + let back: ExportSettings = serde_json::from_value(serde_json::to_value(&s).unwrap()).unwrap(); + assert_eq!(back.audio.sample_format(), SampleFormat::Float32); + // settings saved before 32-bit audio read integers + let old: AudioSettings = serde_json::from_value(serde_json::json!({"bits": 24})).unwrap(); + assert_eq!((old.float, old.sample_format()), (false, SampleFormat::Int24)); + let sum = |sf: SampleFormat, format: Format| { + let mut s = ExportSettings { format, ..Default::default() }; + s.audio.set_sample_format(sf); + ( + s.summary(64, 36, FrameRate::FPS_24, 48_000, Tick(TICKS_PER_SECOND)).audio, + s.estimate_bytes(64, 36, FrameRate::FPS_24, 48_000, Tick(TICKS_PER_SECOND)), + ) + }; + let (f, f_bytes) = sum(SampleFormat::Float32, Format::Wav); + assert!(f.starts_with("Uncompressed 32-bit float PCM, 48000 Hz"), "{f}"); + let (i, _) = sum(SampleFormat::Int32, Format::Wav); + assert!(i.starts_with("Uncompressed 32-bit PCM"), "{i}"); + let (_, i16_bytes) = sum(SampleFormat::Int16, Format::Wav); + assert!(f_bytes > i16_bytes * 19 / 10 && f_bytes < i16_bytes * 21 / 10, "{f_bytes} vs {i16_bytes}"); + let (flac, _) = sum(SampleFormat::Int32, Format::Flac); + assert!(flac.starts_with("FLAC (lossless) 32-bit"), "{flac}"); +} + +/// FilmCraft imports its own 32-bit FLAC (symphonia rejected the frame header's 32-bit size code +/// as reserved: "unsupported media"), at full scale and with noise, sample for sample. +#[test] +fn own_importer_reads_32_bit_flac_at_full_scale() { + let n = 20_000usize; + let mut seed = 7u64; + let noise: Vec = (0..n) + .map(|_| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed >> 32) as u32 as i32 + }) + .collect(); + let extremes: Vec = (0..n).map(|i| if i % 3 == 0 { i32::MAX } else { i32::MIN + 1 }).collect(); + for level in [0u8, 5, 8] { + let mut enc = filmcraft_flac::Encoder::new(filmcraft_flac::EncoderConfig { level, ..filmcraft_flac::EncoderConfig::new(48_000, 2, 32) }).unwrap(); + let mut file = enc.header(); + file.extend(enc.encode(&[&extremes, &noise]).unwrap()); + file.extend(enc.finish()); + let at = filmcraft_flac::STREAMINFO_OFFSET as usize; + file[at..at + 34].copy_from_slice(&enc.streaminfo()); + let src = filmcraft_codecs::open_bytes("x.flac", file.into()).unwrap_or_else(|e| panic!("level {level}: {e}")); + let a = src.audio(0, n, 48_000).unwrap(); + for (c, want) in [&extremes, &noise].iter().enumerate() { + let got = &a.channels[c]; + assert_eq!(got.len(), n, "level {level} channel {c}"); + for (i, (&g, &w)) in got.iter().zip(want.iter()).enumerate() { + let w = (f64::from(w) / 2_147_483_648.0) as f32; + assert!((g - w).abs() <= 1e-6, "level {level} channel {c} sample {i}: {g} vs {w}"); + } + } + } +} diff --git a/crates/flac/README.md b/crates/flac/README.md index 3909ab79..258651ce 100644 --- a/crates/flac/README.md +++ b/crates/flac/README.md @@ -13,7 +13,7 @@ symphonia bootstrap decoder in `filmcraft-codecs`. ```rust use filmcraft_flac::{Encoder, EncoderConfig, STREAMINFO_OFFSET}; -let mut cfg = EncoderConfig::new(48_000, 2, 24); // rate, channels (1–8), bits (8–24) +let mut cfg = EncoderConfig::new(48_000, 2, 24); // rate, channels (1–8), bits (8–32) cfg.level = 8; // 0 fastest … 8 smallest; 5 by default let mut enc = Encoder::new(cfg)?; let mut file = enc.header(); // fLaC + STREAMINFO + VORBIS_COMMENT (vendor) @@ -37,10 +37,11 @@ let last_len = enc.pending_samples(); // duration | Subframes | constant, verbatim, fixed predictors (order 0–4) and linear prediction (up to order 12), whichever is smallest | | Linear prediction | Tukey(0.5) windowed autocorrelation, Levinson–Durbin, 12/15-bit quantised coefficients with error feedback | | Residual | partitioned Rice codes (4- or 5-bit parameters), the partition order chosen per subframe | -| Stereo | independent, left/side, side/right or mid/side per frame, whichever is smallest | +| Stereo | independent, left/side, side/right or mid/side per frame, whichever is smallest (32-bit: independent only, a side channel would need 33 bits) | -Not written: variable block sizes, wasted-bits shifts, escape-coded partitions, 32-bit samples, -seek tables. +Not written: variable block sizes, wasted-bits shifts, escape-coded partitions, seek tables. +32-bit samples (frame headers point at STREAMINFO for the size) decode only in recent decoders (libFLAC 1.4+, +ffmpeg 6+). | Level | LPC order | Rice partition order | Stereo decorrelation | |---|---|---|---| diff --git a/crates/flac/src/frame.rs b/crates/flac/src/frame.rs index 6ccd8a50..285658bf 100644 --- a/crates/flac/src/frame.rs +++ b/crates/flac/src/frame.rs @@ -53,6 +53,8 @@ fn sample_size_code(bps: u8) -> u8 { 16 => 4, 20 => 5, 24 => 6, + // 32 bits has code 7 since RFC 9639, which earlier decoders (symphonia 0.5, FilmCraft's own + // FLAC import) reject as reserved; 0 ("as STREAMINFO says") decodes everywhere _ => 0, } } diff --git a/crates/flac/src/lib.rs b/crates/flac/src/lib.rs index 5e278365..0844b7c3 100644 --- a/crates/flac/src/lib.rs +++ b/crates/flac/src/lib.rs @@ -1,6 +1,6 @@ //! Clean-room FLAC encoder (RFC 9639, Free Lossless Audio Codec). //! -//! - [`Encoder`]: planar integer samples (8–24 bit, 1–8 channels) → a `.flac` stream: the +//! - [`Encoder`]: planar integer samples (8–32 bit, 1–8 channels) → a `.flac` stream: the //! [`Encoder::header`] (`fLaC`, STREAMINFO, a VORBIS_COMMENT naming the encoder), then frames of //! a fixed block size. Each channel of each frame is coded as constant, verbatim, a fixed //! polynomial predictor or linear prediction, whichever is smallest, with partitioned Rice codes; @@ -61,7 +61,8 @@ pub struct EncoderConfig { pub sample_rate: u32, /// 1–8 (FLAC channel order: L R C LFE Ls Rs … for 5.1). pub channels: u8, - /// 8–24. + /// 8–32. At 32 bits stereo channels are coded independently: the side channel of + /// left/side, side/right and mid/side coding would need 33 bits. pub bits_per_sample: u8, /// Samples per channel per frame, 16–65535 (4096 by default). pub block_size: u16, @@ -92,8 +93,8 @@ impl Encoder { if !(1..=8).contains(&cfg.channels) { return Err(Error::InvalidConfig("1 to 8 channels")); } - if !(8..=24).contains(&cfg.bits_per_sample) { - return Err(Error::InvalidConfig("8 to 24 bits per sample")); + if !(8..=32).contains(&cfg.bits_per_sample) { + return Err(Error::InvalidConfig("8 to 32 bits per sample")); } if !(1..=1_048_575).contains(&cfg.sample_rate) { return Err(Error::InvalidConfig("sample rate 1 to 1048575 Hz")); @@ -170,8 +171,9 @@ impl Encoder { if planar.iter().any(|c| c.len() != n) { return Err(Error::UnequalChannels); } - let max = (1i32 << (self.cfg.bits_per_sample - 1)) - 1; - if let Some(&bad) = planar.iter().flat_map(|c| c.iter()).find(|&&s| s > max || s < -max - 1) { + // i64: at 32 bits the range is all of i32 (1 << 31 overflows i32) + let max = (1i64 << (self.cfg.bits_per_sample - 1)) - 1; + if let Some(&bad) = planar.iter().flat_map(|c| c.iter()).find(|&&s| i64::from(s) > max || i64::from(s) < -max - 1) { return Err(Error::SampleRange(bad)); } for (p, c) in self.pending.iter_mut().zip(planar) { @@ -217,7 +219,8 @@ impl Encoder { let effort = self.effort; let code = |x: &[i32], b: u8| predict::choose(x, b, effort); let data = match block { - [l, r] if effort.stereo => { + // the side channel takes one bit more than the samples: at most 32 (i32) + [l, r] if effort.stereo && bps < 32 => { let side: Vec = l.iter().zip(r).map(|(&a, &b)| a - b).collect(); let mid: Vec = l.iter().zip(r).map(|(&a, &b)| (a + b) >> 1).collect(); let (cl, cr, cs, cm) = (code(l, bps), code(r, bps), code(&side, bps + 1), code(&mid, bps)); diff --git a/crates/flac/tests/ffmpeg_oracle.rs b/crates/flac/tests/ffmpeg_oracle.rs index 80a0c753..d3ebeff5 100644 --- a/crates/flac/tests/ffmpeg_oracle.rs +++ b/crates/flac/tests/ffmpeg_oracle.rs @@ -24,7 +24,7 @@ impl Rng { /// Test signals at `bps`, full scale: music-like (two sines and noise), a sweep, white noise, /// a full-scale square, silence and a lone click. fn signal(kind: &str, n: usize, bps: u8, seed: u64) -> Vec { - let max = f64::from((1i32 << (bps - 1)) - 1); + let max = ((1i64 << (bps - 1)) - 1) as f64; let mut r = Rng(seed | 1); (0..n) .map(|i| { @@ -43,7 +43,7 @@ fn signal(kind: &str, n: usize, bps: u8, seed: u64) -> Vec { "click" => f64::from(u8::from(i == n / 2)), _ => 0.0, }; - ((v * max).round() as i32).clamp(-(max as i32) - 1, max as i32) + ((v * max).round() as i64).clamp(-(max as i64) - 1, max as i64) as i32 }) .collect() } @@ -93,13 +93,14 @@ fn round_trip(name: &str, planar: &[Vec], cfg: EncoderConfig, chunk: usize) } // STREAMINFO: total samples, rate, and the MD5 against ffmpeg's hash of the decoded audio. FLAC - // hashes samples unscaled; ffmpeg rescales other sizes (20-bit becomes 24-bit), so only 8, 16 - // and 24 bit compare + // hashes samples unscaled; ffmpeg rescales other sizes (20-bit becomes 24-bit), so only 8, 16, + // 24 and 32 bit compare let si = &file[STREAMINFO_OFFSET as usize..STREAMINFO_OFFSET as usize + 34]; let pcm = match bps { 8 => Some("s8"), 16 => Some("s16le"), 24 => Some("s24le"), + 32 => Some("s32le"), _ => None, }; if let Some(pcm) = pcm { @@ -118,7 +119,7 @@ fn round_trip(name: &str, planar: &[Vec], cfg: EncoderConfig, chunk: usize) #[test] fn every_signal_round_trips_exactly() { for (kind, seed) in [("music", 1), ("sweep", 2), ("noise", 3), ("square", 4), ("silence", 5), ("click", 6)] { - for bps in [16u8, 24] { + for bps in [16u8, 24, 32] { let planar = vec![signal(kind, 20_000, bps, seed), signal(kind, 20_000, bps, seed + 100)]; if round_trip(&format!("{kind}-{bps}"), &planar, EncoderConfig::new(48_000, 2, bps), 4800).is_none() { return; @@ -129,11 +130,13 @@ fn every_signal_round_trips_exactly() { #[test] fn channel_layouts_rates_and_sizes() { - let cases: [(&str, u8, u8, u32, usize); 6] = [ + let cases: [(&str, u8, u8, u32, usize); 8] = [ ("mono-8bit", 1, 8, 8_000, 3_001), ("mono-24-44k1", 1, 24, 44_100, 9_999), ("stereo-20bit", 2, 20, 96_000, 10_000), ("5.1-24", 6, 24, 48_000, 12_345), + ("mono-32", 1, 32, 48_000, 7_777), + ("5.1-32", 6, 32, 96_000, 12_345), ("odd-rate", 2, 16, 37_800, 5_000), ("tiny", 2, 16, 48_000, 1), ]; @@ -167,7 +170,8 @@ fn levels_and_block_sizes_trade_time_for_size() { fn bad_input_is_an_error_not_a_panic() { assert!(Encoder::new(EncoderConfig::new(48_000, 0, 16)).is_err()); assert!(Encoder::new(EncoderConfig::new(48_000, 9, 16)).is_err()); - assert!(Encoder::new(EncoderConfig::new(48_000, 2, 32)).is_err()); + assert!(Encoder::new(EncoderConfig::new(48_000, 2, 33)).is_err()); + assert!(Encoder::new(EncoderConfig::new(48_000, 2, 7)).is_err()); assert!(Encoder::new(EncoderConfig::new(0, 2, 16)).is_err()); assert!(Encoder::new(EncoderConfig { block_size: 8, ..EncoderConfig::new(48_000, 2, 16) }).is_err()); let mut enc = Encoder::new(EncoderConfig::new(48_000, 2, 16)).unwrap(); @@ -179,3 +183,21 @@ fn bad_input_is_an_error_not_a_panic() { assert!(enc.finish().is_empty()); assert_eq!(&enc.header()[..4], b"fLaC"); } + +/// 32-bit stereo at the extremes of i32: left minus right would need 33 bits, so the encoder must +/// not code a side channel (it overflowed `i32` and wrote garbage); every level decodes exactly. +#[test] +fn full_scale_32_bit_stereo_round_trips() { + let n = 9_000; + let left: Vec = (0..n).map(|i| if i % 3 == 0 { i32::MAX } else { i32::MIN }).collect(); + let right: Vec = left.iter().map(|&v| if v == i32::MAX { i32::MIN } else { i32::MAX }).collect(); + let planar = vec![left, right]; + for level in [0u8, 5, 8] { + let cfg = EncoderConfig { level, ..EncoderConfig::new(48_000, 2, 32) }; + if round_trip(&format!("extremes-32-level{level}"), &planar, cfg, 4096).is_none() { + return; + } + } + let mut enc = Encoder::new(EncoderConfig::new(48_000, 2, 32)).unwrap(); + assert!(enc.encode(&[&[i32::MIN, i32::MAX], &[0, -1]]).is_ok()); +} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 25a23480..d5c4e457 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2922,6 +2922,10 @@ Sample Size Tamaño de muestra 16 bit 16 bits 24 bit 24 bits + 32 bit 32 bits + 32 bit float 32 bits de coma flotante + FLAC stores whole-number samples only FLAC solo guarda muestras enteras + MXF export writes integer PCM only La exportación MXF solo escribe PCM entero Size / Opacity Tamaño / opacidad Image Overlay Superposición de imagen PNG or JPEG file Archivo PNG o JPEG diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 4385c0dd..6d55d309 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2922,6 +2922,10 @@ Sample Size Taille d'échantillon 16 bit 16 bits 24 bit 24 bits + 32 bit 32 bits + 32 bit float 32 bits à virgule flottante + FLAC stores whole-number samples only FLAC ne stocke que des échantillons entiers + MXF export writes integer PCM only L’export MXF n’écrit que du PCM entier Size / Opacity Taille / opacité Image Overlay Superposition d'image PNG or JPEG file Fichier PNG ou JPEG diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 1c44965f..b8c160ec 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2927,6 +2927,10 @@ Sample Size Ukuran Sampel 16 bit 16 bit 24 bit 24 bit + 32 bit 32 bit + 32 bit float 32 bit titik mengambang + FLAC stores whole-number samples only FLAC hanya menyimpan sampel bilangan bulat + MXF export writes integer PCM only Ekspor MXF hanya menulis PCM bilangan bulat Size / Opacity Ukuran / Opasitas Image Overlay Overlay Gambar PNG or JPEG file Berkas PNG atau JPEG diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 13673e6c..34bf689c 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2911,6 +2911,10 @@ Sample Size サンプルサイズ 16 bit 16 ビット 24 bit 24 ビット + 32 bit 32 ビット + 32 bit float 32 ビット浮動小数点 + FLAC stores whole-number samples only FLAC は整数サンプルのみ保存できます + MXF export writes integer PCM only MXF 書き出しは整数 PCM のみです Size / Opacity サイズ / 不透明度 Image Overlay 画像のオーバーレイ PNG or JPEG file PNG または JPEG ファイル diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 17498760..9b9edaa4 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2924,6 +2924,10 @@ Sample Size Tamanho da amostra 16 bit 16 bits 24 bit 24 bits + 32 bit 32 bits + 32 bit float 32 bits de ponto flutuante + FLAC stores whole-number samples only O FLAC só armazena amostras inteiras + MXF export writes integer PCM only A exportação MXF só grava PCM inteiro Size / Opacity Tamanho / opacidade Image Overlay Sobreposição de imagem PNG or JPEG file Arquivo PNG ou JPEG diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index 7459caa1..16d6114b 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -2905,6 +2905,10 @@ Sample Size 采样大小 16 bit 16 位 24 bit 24 位 + 32 bit 32 位 + 32 bit float 32 位浮点 + FLAC stores whole-number samples only FLAC 只能存储整数采样 + MXF export writes integer PCM only MXF 导出仅写入整数 PCM Size / Opacity 大小 / 不透明度 Image Overlay 图像叠加 PNG or JPEG file PNG 或 JPEG 文件 diff --git a/crates/ui-egui/src/panels/export_mode.rs b/crates/ui-egui/src/panels/export_mode.rs index 0716f7ad..9dac6500 100644 --- a/crates/ui-egui/src/panels/export_mode.rs +++ b/crates/ui-egui/src/panels/export_mode.rs @@ -24,7 +24,7 @@ use egui::{Align2, Color32, Rect, Sense, pos2, vec2}; use filmcraft_engine::export::presets::{DEFAULT_PRESET, preset_key}; use filmcraft_engine::export::{ AudioCodec, BitrateMode, ExportSettings, ExrCompression, FieldOrder, Format, GpuRendering, H264Profile, HardwareEncoding, Multiplexer, MxfVideoCodec, - Placement, Scaling, TextOverlay, builtin_presets, format_bytes, + Placement, SampleFormat, Scaling, TextOverlay, builtin_presets, format_bytes, }; use filmcraft_engine::time::{FrameRate, Tick}; use serde::{Deserialize, Serialize}; @@ -310,11 +310,22 @@ fn row(ui: &mut egui::Ui, t: &Tokens, label: &str, add: impl FnOnce(&mut egui /// A combo box over `options` (label, enabled); returns the chosen index. fn combo(ui: &mut egui::Ui, reg: &mut Reg, id: &str, selected: &str, options: &[(String, bool)], width: f32) -> Option { + let why = tl!("Not supported by FilmCraft's encoders yet"); + let options: Vec<(String, Option<&str>)> = options.iter().map(|(l, on)| (l.clone(), (!on).then_some(why))).collect(); + combo_why(ui, reg, id, selected, &options, width) +} + +/// [`combo`] whose options carry why they are greyed out (label, `Some(reason)` when disabled). +fn combo_why(ui: &mut egui::Ui, reg: &mut Reg, id: &str, selected: &str, options: &[(String, Option<&str>)], width: f32) -> Option { let mut chosen = None; let r = egui::ComboBox::from_id_salt(id).selected_text(crate::i18n::t(selected)).width(width).show_ui(ui, |ui| { - for (i, (label, enabled)) in options.iter().enumerate() { - let resp = ui.add_enabled(*enabled, egui::Button::selectable(label == selected, crate::i18n::t(label))); - let resp = if *enabled { resp } else { resp.on_disabled_hover_text(tl!("Not supported by FilmCraft's encoders yet")) }; + for (i, (label, why)) in options.iter().enumerate() { + let enabled = why.is_none(); + let resp = ui.add_enabled(enabled, egui::Button::selectable(label == selected, crate::i18n::t(label))); + let resp = match why { + Some(why) => resp.on_disabled_hover_text(*why), + None => resp, + }; reg.add(format!("{id}.option.{i}"), resp.rect, label.clone()); if resp.clicked() { chosen = Some(i); @@ -902,10 +913,22 @@ fn audio_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T }); } else { row(ui, t, tl!("Sample Size"), |ui| { - let sizes = [tl!("16 bit"), tl!("24 bit")]; - let cur = if s.audio.bits >= 24 { sizes[1] } else { sizes[0] }; - if let Some(i) = combo(ui, reg, "export.audio.bits", cur, &opts(&sizes), 120.0) { - s.audio.bits = if i == 0 { 16 } else { 24 }; + // every size, the ones this codec and container cannot store greyed out; a size the + // audio format no longer takes (float after switching to FLAC) becomes 32-bit integer + if !s.supports_sample_format(s.audio.sample_format()) { + s.audio.set_sample_format(SampleFormat::Int32); + } + let sizes = [tl!("16 bit"), tl!("24 bit"), tl!("32 bit"), tl!("32 bit float")]; + let why = |f: SampleFormat| match (s.supports_sample_format(f), s.audio_codec()) { + (true, _) => None, + (false, AudioCodec::Flac) => Some(tl!("FLAC stores whole-number samples only")), + (false, _) if s.format.is_mxf() => Some(tl!("MXF export writes integer PCM only")), + (false, _) => Some(tl!("Not supported by FilmCraft's encoders yet")), + }; + let labels: Vec<(String, Option<&str>)> = SampleFormat::ALL.iter().zip(sizes).map(|(&f, l)| (l.to_string(), why(f))).collect(); + let cur = SampleFormat::ALL.iter().position(|&f| f == s.audio.sample_format()).and_then(|i| sizes.get(i).copied()).unwrap_or(sizes[0]); + if let Some(&f) = combo_why(ui, reg, "export.audio.bits", cur, &labels, 120.0).and_then(|i| SampleFormat::ALL.get(i)) { + s.audio.set_sample_format(f); } }); } diff --git a/crates/ui-egui/tests/export_ui.rs b/crates/ui-egui/tests/export_ui.rs index 5739290d..6a337525 100644 --- a/crates/ui-egui/tests/export_ui.rs +++ b/crates/ui-egui/tests/export_ui.rs @@ -441,3 +441,72 @@ fn export_mode_file_name_can_be_emptied_and_retyped() { d.frames(2); assert_eq!(d.app().ui.export.file_name, "Final"); } + +/// Sample Size lists every size (16, 24, 32 bit and 32 bit float) the way Audio Format lists every +/// codec: what the chosen audio format and container cannot store is shown greyed out and cannot +/// be picked, and a size that stops being available (float after switching to FLAC) becomes +/// 32-bit integer. +#[test] +fn sample_size_lists_every_size_and_greys_out_what_the_format_cannot_store() { + use filmcraft_engine::export::{AudioCodec, ExportSettings, Format, SampleFormat}; + let mut d = Driver::new("sample-size"); + d.ok("ui.set", json!({"mode": "export"})); + d.frames(4); + // collapse Video so the Audio section sits under the format + d.click("export.section.video"); + d.app().ui.export.settings = ExportSettings { format: Format::Wav, ..Default::default() }; + d.frames(4); + let sizes = ["16 bit", "24 bit", "32 bit", "32 bit float"]; + let open_sizes = |d: &mut Driver| { + if !d.has("export.audio.bits.option.0") { + d.click("export.audio.bits"); + } + for (i, label) in sizes.iter().enumerate() { + let id = format!("export.audio.bits.option.{i}"); + let e = d.ok("ui.elements", json!({"prefix": id})); + assert_eq!(e.as_array().and_then(|a| a.iter().find(|e| e["id"] == json!(id))).map(|e| e["label"].clone()), Some(json!(label)), "{id}"); + } + }; + + // WAV: all four; pick 32 bit float + open_sizes(&mut d); + d.snapshot("export-sample-size-wav"); + d.click("export.audio.bits.option.3"); + assert_eq!(d.app().ui.export.settings.audio.sample_format(), SampleFormat::Float32); + + // FLAC: float is greyed out, so the float choice becomes 32-bit integer and clicking float + // changes nothing + d.app().ui.export.settings.format = Format::Flac; + d.frames(4); + assert_eq!(d.app().ui.export.settings.audio_codec(), AudioCodec::Flac); + assert_eq!(d.app().ui.export.settings.audio.sample_format(), SampleFormat::Int32); + open_sizes(&mut d); + d.snapshot("export-sample-size-flac"); + d.click("export.audio.bits.option.3"); + assert_eq!(d.app().ui.export.settings.audio.sample_format(), SampleFormat::Int32, "32 bit float is not available for FLAC"); + if d.has("export.audio.bits.option.0") { + d.click("export.audio.bits"); + } + d.click("export.audio.bits"); + d.click("export.audio.bits.option.1"); + assert_eq!(d.app().ui.export.settings.audio.sample_format(), SampleFormat::Int24); + + // MXF: integer PCM only + d.app().ui.export.settings = ExportSettings { format: Format::MxfOp1a, ..Default::default() }; + d.frames(4); + open_sizes(&mut d); + d.snapshot("export-sample-size-mxf"); + d.click("export.audio.bits.option.3"); + assert!(!d.app().ui.export.settings.audio.float, "32 bit float is not available for MXF"); + if d.has("export.audio.bits.option.0") { + d.click("export.audio.bits"); + } + + // QuickTime PCM (ProRes): all four again + d.app().ui.export.settings = ExportSettings { format: Format::ProRes, ..Default::default() }; + d.frames(4); + open_sizes(&mut d); + d.click("export.audio.bits.option.3"); + assert_eq!(d.app().ui.export.settings.audio.sample_format(), SampleFormat::Float32); + d.snapshot("export-sample-size-prores"); +} diff --git a/docs/architecture.md b/docs/architecture.md index e98ed51d..f8995621 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -668,11 +668,11 @@ file.exportMedia {path, preset?, settings?, format?, range?, …} export.qui | `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-op1a` | DNxHR (default), ProRes or H.264 (Annex B, long GOP), `mxfVideoCodec`; PCM 16/24/32-bit integer | 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`, `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) | +| `gif`, `wav`, `aiff` | built in / `image` | GIF / RIFF WAVE (`WAVE_FORMAT_EXTENSIBLE` for 5.1; `WAVE_FORMAT_IEEE_FLOAT` + `fact` for float) / AIFF (16-, 24- or 32-bit PCM; 32-bit float as AIFF-C `fl32`) | - **Reproducible.** The same project and settings give the same file on every machine. Frames render in batches of one per core, but the muxer gets them in fixed groups of 16 output frames @@ -682,7 +682,9 @@ file.exportMedia {path, preset?, settings?, format?, range?, …} export.qui - **Settings.** `ExportSettings` (serde, camelCase, every field optional) holds Video (frame size or Match Source, frame rate, Scale to Fit / Fill / Stretch, pixel aspect, field order — progressive only, profile / level, bitrate encoding, target / maximum / adaptive bitrate, keyframe distance, - maximum render quality), Audio (codec, sample rate, channels, AAC bitrate, PCM sample size), + maximum render quality), Audio (codec, sample rate, channels, AAC bitrate, PCM / FLAC sample size: `bits` 16, 24 or 32 + and `float` for 32-bit float PCM in WAV, AIFF and QuickTime — `supports_sample_format` says + which a codec and container store, and Export mode greys out the rest), Multiplexer (audio channels: mono, stereo or 5.1, §5.1.1), Captions (burn-in or SRT / WebVTT sidecar), Effects (image / name / timecode overlays, video limiter, loudness normalization), Metadata (`udta` `©nam`, `©ART`, `©cpy`, `©des`, `©cmt`). `settings.summary()` / `estimate_bytes()` feed Export mode's Summary. From 917d23fdffc3136f9953814e24487d5ec721f3ea Mon Sep 17 00:00:00 2001 From: Ikki Nishihara Date: Mon, 12 Oct 2026 11:46:30 +0900 Subject: [PATCH 08/73] =?UTF-8?q?fix(timeline):=20=E3=82=AF=E3=83=AA?= =?UTF-8?q?=E3=83=83=E3=83=97=E3=81=AE=E5=8F=B3=E3=82=AF=E3=83=AA=E3=83=83?= =?UTF-8?q?=E3=82=AF=E3=81=AB=E3=83=9E=E3=83=BC=E3=82=AB=E3=83=BC=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0=E3=82=92=E7=94=A8=E6=84=8F=E3=81=99=E3=82=8B=20(#1184?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/ui-egui/src/panels/timeline.rs | 1 + crates/ui-egui/tests/clip_dialogs_ui.rs | 27 +++++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index ac4caf53..f1e079bc 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1909,6 +1909,7 @@ pub(crate) const CLIP_MENU: &[&[(&str, &str)]] = &[ ("Multi-Camera", "clip.multicam"), ], &[("Label", "edit.label")], + &[("Add Marker", "markers.add")], &[("Speed/Duration…", "clip.speedDuration"), ("Scene Edit Detection…", "clip.sceneEditDetection")], &[ ("Frame Hold Options…", "clip.frameHoldOptions"), diff --git a/crates/ui-egui/tests/clip_dialogs_ui.rs b/crates/ui-egui/tests/clip_dialogs_ui.rs index 68249a2c..72c947fb 100644 --- a/crates/ui-egui/tests/clip_dialogs_ui.rs +++ b/crates/ui-egui/tests/clip_dialogs_ui.rs @@ -419,3 +419,30 @@ fn escape_mid_drag_reverts_the_field_and_keeps_the_dialog() { 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"); } + +#[test] +fn clip_context_menu_adds_a_marker_at_the_playhead_with_one_undo() { + for audio in [false, true] { + let mut d = Driver::new(); + d.exec("markers.clearAll", json!({})); + d.exec("playhead.set", json!({"frame": 35})); + let seq = d.app().session.active_sequence().unwrap(); + let clip = if audio { seq.audio_tracks[0].items[0].id } else { seq.video_tracks[0].items[0].id }; + let playhead = d.app().session.playhead(); + let before = d.app().session.project.clone(); + let history = d.app().session.history.undo.len(); + d.ok("ui.click", json!({"id": format!("timeline.clip.{}", clip.0), "button": "right", "fy": 0.3})); + d.frames(3); + assert!(d.has("timeline.clipMenu.markers.add"), "Add Marker is available on video and audio clips"); + d.click("timeline.clipMenu.markers.add"); + let markers = &d.app().session.active_sequence().unwrap().markers; + assert_eq!(markers.len(), 1); + assert_eq!(markers[0].start, playhead); + assert_eq!(d.app().session.history.undo.len(), history + 1); + let after = d.app().session.project.clone(); + d.exec("edit.undo", json!({})); + assert_eq!(serde_json::to_value(&*d.app().session.project).unwrap(), serde_json::to_value(&*before).unwrap()); + d.exec("edit.redo", json!({})); + assert_eq!(serde_json::to_value(&*d.app().session.project).unwrap(), serde_json::to_value(&*after).unwrap()); + } +} From 50dd69ce32ad5e539dcacfd71fc01c3b7d39b696 Mon Sep 17 00:00:00 2001 From: Akash Bhat Date: Mon, 12 Oct 2026 13:02:48 +0530 Subject: [PATCH 09/73] fix(apv): skip oracle tests when ffmpeg predates APV support The APV demuxer is new in ffmpeg 8.0, so ubuntu-24.04 runners failed instead of skipping. testkit gains ffmpeg_with_format_or_skip: when ffmpeg -formats does not list apv it SKIPs naming the detected version, failing only under FILMCRAFT_REQUIRE_ORACLES=1. The apv oracle uses it; docs/testing.md notes ffmpeg >= 8.0. Fixes #1249. --- crates/apv/tests/oracle.rs | 4 +- crates/testkit/src/lib.rs | 4 +- crates/testkit/src/oracle.rs | 87 ++++++++++++++++++++++++++++++++++++ docs/testing.md | 2 + 4 files changed, 95 insertions(+), 2 deletions(-) diff --git a/crates/apv/tests/oracle.rs b/crates/apv/tests/oracle.rs index f23c32b5..8b9b87a1 100644 --- a/crates/apv/tests/oracle.rs +++ b/crates/apv/tests/oracle.rs @@ -8,7 +8,9 @@ use filmcraft_apv::{ }; fn ffmpeg() -> Option { - filmcraft_testkit::ffmpeg_or_skip("apv oracle") + // The APV demuxer is new in ffmpeg 8.0; older toolchains are found but + // cannot read the fixtures, so they skip like a missing tool (#1249). + filmcraft_testkit::ffmpeg_with_format_or_skip("apv oracle", "apv", "8.0") } fn fixture_dir() -> PathBuf { diff --git a/crates/testkit/src/lib.rs b/crates/testkit/src/lib.rs index 867df7b3..9846d907 100644 --- a/crates/testkit/src/lib.rs +++ b/crates/testkit/src/lib.rs @@ -16,7 +16,9 @@ pub mod oracle; pub mod wav; pub use fixtures::{fixtures_dir, temp_path, workspace_root}; -pub use oracle::{ffmpeg, ffmpeg_or_skip, ffprobe, ffprobe_or_skip, oracles_required, skip}; +pub use oracle::{ + ffmpeg, ffmpeg_or_skip, ffmpeg_supports_format, ffmpeg_version, ffmpeg_with_format_or_skip, ffprobe, ffprobe_or_skip, oracles_required, skip, +}; /// `let ff = require_ffmpeg!();` — the ffmpeg path, or return from the test after printing /// `SKIPPED: … ffmpeg not found` (a hard failure with `FILMCRAFT_REQUIRE_ORACLES=1`). diff --git a/crates/testkit/src/oracle.rs b/crates/testkit/src/oracle.rs index 8dbdbd3f..e0b13863 100644 --- a/crates/testkit/src/oracle.rs +++ b/crates/testkit/src/oracle.rs @@ -121,6 +121,59 @@ pub fn ffprobe_or_skip(context: &str) -> Option { tool_or_skip("ffprobe", ffprobe(), context) } +/// Whether `ffmpeg -formats` output lists `format_name` (e.g. `apv`, new in +/// ffmpeg 8.0). Pure function of the command output; matches a whole +/// whitespace-separated token so `APV` in a description cannot match. +fn formats_output_lists(output: &str, format_name: &str) -> bool { + output.lines().any(|line| line.split_whitespace().any(|token| token == format_name)) +} + +/// Whether `ff` can read `format_name`. An ffmpeg that cannot run (or prints +/// nothing usable) counts as unsupported; the caller skips rather than fails. +pub fn ffmpeg_supports_format(ff: &Path, format_name: &str) -> bool { + let Ok(out) = std::process::Command::new(ff).args(["-hide_banner", "-formats"]).output() else { + return false; + }; + if !out.status.success() { + return false; + } + formats_output_lists(&String::from_utf8_lossy(&out.stdout), format_name) +} + +/// The `ffmpeg -version` version string in banner output (`6.1.1-3ubuntu5`, +/// `8.0.1`, …), if the first line is an ffmpeg banner. Pure function of the +/// command output. +fn ffmpeg_version_in(output: &str) -> Option { + let first = output.lines().next()?; + let rest = first.strip_prefix("ffmpeg version")?.trim_start(); + let version: String = rest.chars().take_while(|c| !c.is_whitespace()).collect(); + if version.is_empty() { None } else { Some(version) } +} + +/// The `ffmpeg -version` version string (`6.1.1-3ubuntu5`, `8.0.1`, …), if `ff` +/// reports one. `None` when the tool cannot run or its banner is unexpected. +pub fn ffmpeg_version(ff: &Path) -> Option { + let out = std::process::Command::new(ff).arg("-version").output().ok()?; + if !out.status.success() { + return None; + } + ffmpeg_version_in(&String::from_utf8_lossy(&out.stdout)) +} + +/// ffmpeg that can read `format_name`, or `None` after reporting a skip (see +/// [`skip`]). An ffmpeg predating the format (APV needs 8.0+) skips with its +/// detected version named, exactly like a missing tool — and fails under +/// `FILMCRAFT_REQUIRE_ORACLES=1`. +pub fn ffmpeg_with_format_or_skip(context: &str, format_name: &str, min_version: &str) -> Option { + let ff = ffmpeg_or_skip(context)?; + if ffmpeg_supports_format(&ff, format_name) { + return Some(ff); + } + let version = ffmpeg_version(&ff).map_or_else(|| "unknown version".to_string(), |v| format!("version {v}")); + skip(context, &format!("ffmpeg {version} cannot read {format_name} (needs {min_version}+)")); + None +} + #[cfg(test)] mod tests { use super::*; @@ -167,4 +220,38 @@ mod tests { assert_eq!(got, Some(d.join("ffmpeg"))); assert_eq!(find_tool_in("ffmpeg-does-not-exist", None, None, &[ds.as_str()]).unwrap(), None); } + + const FORMATS_WITH_APV: &str = + "File formats:\n D mov QuickTime / MOV\n DE apv APV raw bitstream\n D mp4 MP4 (MPEG-4 Part 14)\n"; + const FORMATS_WITHOUT_APV: &str = + "File formats:\n D mov QuickTime / MOV\n D mp4 MP4 (MPEG-4 Part 14)\n D oapv Obsolete APV alias\n"; + + #[test] + fn formats_listing_detects_a_supported_format() { + assert!(formats_output_lists(FORMATS_WITH_APV, "apv")); + // No apv entry: neither a longer name containing it (oapv) nor an + // uppercase description mention (APV) counts as support. + assert!(!formats_output_lists(FORMATS_WITHOUT_APV, "apv")); + assert!(!formats_output_lists("", "apv")); + } + + #[test] + fn version_string_parses_ffmpeg_banner() { + assert_eq!(ffmpeg_version_in("ffmpeg version 7:6.1.1-3ubuntu5 Copyright (c) 2000-2023"), Some("7:6.1.1-3ubuntu5".to_string())); + assert_eq!(ffmpeg_version_in("ffmpeg version 8.0.1-full_build-www.gyan.dev xxx"), Some("8.0.1-full_build-www.gyan.dev".to_string())); + assert_eq!(ffmpeg_version_in(""), None); + assert_eq!(ffmpeg_version_in("ffprobe version 6.1"), None); + assert_eq!(ffmpeg_version_in("ffmpeg version "), None); + } + + #[test] + fn live_ffmpeg_reports_its_own_apv_support() { + let Some(ff) = ffmpeg() else { return }; + let supported = ffmpeg_supports_format(&ff, "apv"); + assert_eq!( + supported, + formats_output_lists(&String::from_utf8_lossy(&std::process::Command::new(&ff).args(["-hide_banner", "-formats"]).output().unwrap().stdout), "apv") + ); + assert!(ffmpeg_version(&ff).is_some_and(|v| !v.is_empty())); + } } diff --git a/docs/testing.md b/docs/testing.md index 98ab6b6e..67d312fa 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -52,6 +52,8 @@ are never linked or shipped ([AGENTS.md](../AGENTS.md) §2). - If a tool is missing the test prints `SKIPPED (): ffmpeg not found …` and passes. Set **`FILMCRAFT_REQUIRE_ORACLES=1`** (CI) to make every such skip a failure. In new tests use `let ff = filmcraft_testkit::require_ffmpeg!();` (also `require_ffprobe!`, `require_oracles!`). +- Some oracles need a recent ffmpeg: the APV tests (`crates/apv/tests/oracle.rs`) skip when + `ffmpeg -formats` does not list `apv` (ffmpeg ≥ 8.0), naming the detected version. - Fixtures are generated on first use into `/target/fixtures//` (`filmcraft_testkit::fixtures_dir`; independent of `CARGO_TARGET_DIR`, so agents with private target dirs share them; `FILMCRAFT_FIXTURES_DIR` overrides the root) and reused afterwards. From fe3c60b97ad4503fabd5eb66eb74e3d85b239b58 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:36:52 +0200 Subject: [PATCH 10/73] test(i18n): skip the Japanese language step when no Japanese font is available (#1251) --- crates/ui-egui/src/i18n/mod.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 9dd1ad9b..937c68bb 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -858,11 +858,14 @@ mod tests { fn language_commands_switch_and_persist_without_a_document() { let mut app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); let ctx = egui::Context::default(); - crate::menus::invoke(&mut app, &ctx, "app.language.japanese", serde_json::json!({})).unwrap(); - assert_eq!(app.ui.language, Language::Ja); - let saved = serde_json::to_string(&app.ui).unwrap(); - let restored: crate::state::UiState = serde_json::from_str(&saved).unwrap(); - assert_eq!(restored.language, Language::Ja); + // Japanese needs an installed font (see `japanese_needs_an_installed_font`); skip that step without one + if craft_japanese_font() || system_japanese_font().is_some() { + crate::menus::invoke(&mut app, &ctx, "app.language.japanese", serde_json::json!({})).unwrap(); + assert_eq!(app.ui.language, Language::Ja); + let saved = serde_json::to_string(&app.ui).unwrap(); + let restored: crate::state::UiState = serde_json::from_str(&saved).unwrap(); + assert_eq!(restored.language, Language::Ja); + } crate::menus::invoke(&mut app, &ctx, "app.language.spanish", serde_json::json!({})).unwrap(); assert_eq!(app.ui.language, Language::Es); let prefs = serde_json::to_string(&app.session.prefs).unwrap(); From 37a845bc688aec491852076d685806be1c09c168 Mon Sep 17 00:00:00 2001 From: NDelic Date: Mon, 12 Oct 2026 11:17:58 +0200 Subject: [PATCH 11/73] #1241: Select All in Effect Controls selects its keyframes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With Effect Controls focused, Edit ▸ Select All (⌘A / Ctrl+A) selected every clip in the Timeline. It now selects every keyframe the panel shows (effect parameters and mask parameters of the shown clips) and leaves the Timeline selection alone. With no keyframes shown, or another panel focused, Select All behaves as before. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/panels/effect_controls.rs | 27 ++++++++++++++++++++ crates/ui-egui/tests/keyframes_ui.rs | 26 +++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 04d79ac3..10587abf 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -176,7 +176,7 @@ 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) { + if id == "edit.selectAll" && (crate::panels::graphics::select_all_edited_text(app) || crate::panels::effect_controls::select_all_keyframes(app)) { return Ok(Value::Null); } if filmcraft_engine::source_monitor::source_command(id) && targets_source(app, ¶ms) && params.get("target").is_none() { diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 1750022a..962e6e36 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -63,6 +63,33 @@ fn keyframe_exists(it: &TrackItem, k: &KeyframeRef) -> bool { param.is_some_and(|p| p.keyframes.iter().any(|kf| kf.time == k.time)) } +/// Edit ▸ Select All (⌘A) with Effect Controls focused selects every keyframe the panel shows +/// instead of the Timeline's clips (#1241). False when the panel is not focused or shows none. +pub(crate) fn select_all_keyframes(app: &mut FilmcraftApp) -> bool { + if app.ui.focused != crate::dock::PanelKind::EffectControls { + return false; + } + let mut keys = Vec::new(); + for (clip, it, _) in selected_clips(app) { + for (effect, e) in it.effects.iter().enumerate() { + let key = |param: &str, mask, time| KeyframeRef { clip: clip.0, effect, param: param.to_string(), mask, time }; + for (param, p) in &e.params { + keys.extend(p.keyframes.iter().map(|kf| key(param, None, kf.time))); + } + for (m, mask) in e.masks.iter().enumerate() { + for param in filmcraft_project::mask::MASK_PARAMS { + keys.extend(mask.param(param).into_iter().flat_map(|p| &p.keyframes).map(|kf| key(param, Some(m), kf.time))); + } + } + } + } + if keys.is_empty() { + return false; + } + app.ui.keyframe_selection = keys; + true +} + fn selected_clip(app: &FilmcraftApp) -> Option<(ClipId, TrackItem, TrackKind)> { selected_clips(app).into_iter().next() } diff --git a/crates/ui-egui/tests/keyframes_ui.rs b/crates/ui-egui/tests/keyframes_ui.rs index 3edd7ef5..d0549a01 100644 --- a/crates/ui-egui/tests/keyframes_ui.rs +++ b/crates/ui-egui/tests/keyframes_ui.rs @@ -311,6 +311,32 @@ fn clicking_a_lane_keyframe_selects_only_that_keyframe() { assert_eq!(selection(&mut d), json!([]), "a deleted keyframe is not selected"); } +#[test] +fn select_all_in_effect_controls_selects_its_keyframes_not_the_clips() { + // #1241 + let (mut d, clip) = Driver::demo(); + d.seek(1.0); + for param in ["scale", "position"] { + d.exec("effects.addKeyframe", json!({"clip": clip.id, "effect": "motion", "param": param})); + } + d.seek(2.0); + d.exec("effects.addKeyframe", json!({"clip": clip.id, "effect": "motion", "param": "scale"})); + let clips = d.ok("ui.inspect", json!({}))["selection"].clone(); + d.ok("ui.set", json!({"focused": "EffectControls"})); + d.ok("ui.key", json!({"key": "Cmd+A"})); + d.frames(2); + let inspect = d.ok("ui.inspect", json!({})); + let keys = inspect["ui"]["keyframe_selection"].as_array().cloned().unwrap_or_default(); + assert_eq!(keys.len(), 3, "{keys:?}"); + assert!(keys.iter().all(|k| k["clip"] == clip.id)); + assert_eq!(inspect["selection"], clips, "the Timeline selection is unchanged"); + // with the Timeline focused, Select All still selects the clips + d.ok("ui.set", json!({"focused": "Timeline"})); + d.ok("ui.key", json!({"key": "Cmd+A"})); + d.frames(2); + assert!(d.ok("ui.inspect", json!({}))["selection"].as_array().map_or(0, Vec::len) > clips.as_array().map_or(0, Vec::len)); +} + /// 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. From d74ed39c24914bd850c45cbe400e95a1edb87d48 Mon Sep 17 00:00:00 2001 From: NDelic Date: Mon, 12 Oct 2026 11:17:58 +0200 Subject: [PATCH 12/73] Contributors: add n-delic Co-Authored-By: Claude Opus 5.5 --- contributors/people.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 04ed7243..5a64a93d 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -77,3 +77,7 @@ sync_github_name = true real_name = "Valentin Klein" display_name = "Valentin Klein" sync_github_name = false + +[people.n-delic] +display_name = "n-delic" +sync_github_name = false From d46699594287737bea3594c0c450511b8f4eb615 Mon Sep 17 00:00:00 2001 From: NDelic Date: Mon, 12 Oct 2026 11:30:31 +0200 Subject: [PATCH 13/73] #1023: the eyedropper picks from the Program monitor again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With a colour parameter's eyedropper armed (Color Key, Ultra Key, …), a click on the Program picture did nothing: the selected clip's on-monitor transform handles (#639) are drawn over the picture and took the click. The clip is always selected while its effect shows in Effect Controls, so the eyedropper never fired. While an eyedropper is armed the picture overlays (transform handles, masks, graphics) are not drawn, so the click reaches it; they come back once the colour is picked or Esc disarms it. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/monitor.rs | 13 +++++++++---- crates/ui-egui/tests/scripted.rs | 23 +++++++++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels/monitor.rs b/crates/ui-egui/src/panels/monitor.rs index 21a04dc3..06235333 100644 --- a/crates/ui-egui/src/panels/monitor.rs +++ b/crates/ui-egui/src/panels/monitor.rs @@ -295,10 +295,15 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) 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); - crate::panels::graphics::monitor_overlay(app, ui, pic, frame_size); - crate::panels::masks::monitor_overlay(app, ui, pic, frame_size); - crate::panels::transform_handles::monitor_overlay(app, ui, pic, frame_size); + // an armed eyedropper owns clicks on the picture: the overlays would sit on top and take + // them (the selected clip's transform handles cover it, #1023) + if app.ui.eyedropper.is_some() { + eyedropper(app, ui, &pic_resp, pic, target, rate, time); + } else { + crate::panels::graphics::monitor_overlay(app, ui, pic, frame_size); + crate::panels::masks::monitor_overlay(app, ui, pic, frame_size); + crate::panels::transform_handles::monitor_overlay(app, ui, pic, frame_size); + } } if show_picture { monitor_view::guides(app, ui, which, video_area, pic, frame_size); diff --git a/crates/ui-egui/tests/scripted.rs b/crates/ui-egui/tests/scripted.rs index b5f105c7..33f62f53 100644 --- a/crates/ui-egui/tests/scripted.rs +++ b/crates/ui-egui/tests/scripted.rs @@ -577,3 +577,26 @@ fn recording_a_shortcut_skips_the_modifier_key_presses() { assert!(got.contains("Alt+Shift+J"), "{got}"); assert!(got.contains("Cmd+K"), "{got}"); } + +#[test] +fn eyedropper_picks_a_key_color_on_a_selected_clip() { + // #1023: the selected clip's transform handles covered the Program picture and took the click + let mut d = Driver::demo(); + let clip = track_clips(&d.sequence(), 0)[0]["clip"].clone(); + d.exec("timeline.select", json!({"clips": [clip]})); + d.exec("effects.apply", json!({"effect": "Color Key"})); + d.ok("ui.panel.show", json!({"panel": "EffectControls"})); + d.frames(4); + let color = |d: &mut Driver| d.sequence()["video"][0]["items"][0]["effects"][0]["params"]["color"]["value"].clone(); + let before = color(&mut d); + d.ok("ui.click", json!({"id": "effectControls.color_key.color.eyedropper"})); + d.frames(2); + d.ok("ui.click", json!({"id": "program.picture"})); + for _ in 0..200 { + if color(&mut d) != before { + break; + } + d.frames(1); + } + assert_ne!(color(&mut d), before, "the eyedropper filled Key Color from the Program picture"); +} From ec1570a4d8fb3fe83bbe1e4461394c64b18fdd6f Mon Sep 17 00:00:00 2001 From: NDelic Date: Mon, 12 Oct 2026 11:34:58 +0200 Subject: [PATCH 14/73] #1081: Align to Video Frame works on selected video and image clips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Graphics ▸ Align to Video Frame (Left, Center Horizontally, Right, Top, Center Vertically, Bottom) only reached layers inside a graphic clip. With video or image clips selected instead, it now aligns each clip's scaled and rotated bounds to the frame by moving Motion ▸ Position only, at the playhead (a keyframe only when Position is already animated, as typing the value would). Scale, rotation and other keyframes stay untouched; one undo step. A selected or named graphic clip still aligns its layers. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/graphics.rs | 87 ++++++++++++++++++++++++++--- crates/engine/src/graphics_tests.rs | 31 ++++++++++ 2 files changed, 110 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/graphics.rs b/crates/engine/src/graphics.rs index 83e98712..998b1e2f 100644 --- a/crates/engine/src/graphics.rs +++ b/crates/engine/src/graphics.rs @@ -514,8 +514,79 @@ fn union_box(boxes: &[(usize, Vec2, [f64; 4])]) -> [f64; 4] { boxes.iter().fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |a, b| [a[0].min(b.2[0]), a[1].min(b.2[1]), a[2].max(b.2[2]), a[3].max(b.2[3])]) } -/// Graphics ▸ Align to Video Frame / Align to Video Frame as Group / Align to Selection. +/// Video clips (not graphics) the align commands move by their Motion ▸ Position (#1081): `clip`, +/// else the selected ones. Empty when a graphic clip is named or selected: its layers align. +fn align_target_clips(s: &Session, p: &Value) -> Vec { + let Some(seq) = s.active_sequence() else { return Vec::new() }; + let video = |c: &ClipId| seq.find_item(*c).is_some_and(|(t, _)| seq.track(t).is_some_and(|t| t.kind == TrackKind::Video)) && !is_graphic(s, seq, *c); + match u64_p(p, "clip").map(ClipId) { + Some(c) => [c].into_iter().filter(video).collect(), + None if s.state.selection.iter().any(|c| is_graphic(s, seq, *c)) => Vec::new(), + None => s.state.selection.iter().copied().filter(video).collect(), + } +} + +fn has_graphic_or_clip(s: &Session) -> std::result::Result<(), String> { + has_seq(s)?; + if align_target_clips(s, &Value::Null).is_empty() { has_graphic(s).map_err(|_| "select a graphic or video clip".into()) } else { Ok(()) } +} + +/// Align video clips to the frame (each, as a group, or to their union) by moving Motion ▸ +/// Position only, from the clip's scaled and rotated bounds at the playhead. Position gets a +/// keyframe there only when it is already animated, as typing a value would. +fn align_clips(s: &mut Session, clips: &[ClipId], how: &str, mode: AlignTo) -> Result { + let seq = s.active_sequence().ok_or(EngineError::NoSequence)?; + let t = s.playhead(); + let at = |it: &filmcraft_project::TrackItem| it.source_time_at(t.min(it.end() - Tick(1)).max(it.start)); + let frame = [0.0, 0.0, seq.settings.width as f64, seq.settings.height as f64]; + let mut boxes = Vec::new(); + for (i, &c) in clips.iter().enumerate() { + let Some((_, it)) = seq.find_item(c) else { continue }; + let Some(src) = s.project.source_size(it.item) else { continue }; + let Some(motion) = it.effect("motion") else { continue }; + let mt = at(it); + let m = filmcraft_render::motion_matrix(seq, it, src, s.project.source_par(it.item), mt); + let (w, h) = (src.0 as f64, src.1 as f64); + let corners = [(0.0, 0.0), (w, 0.0), (w, h), (0.0, h)].map(|(x, y)| m.apply(Vec2::new(x, y))); + let b = corners.iter().fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.x), b[1].min(p.y), b[2].max(p.x), b[3].max(p.y)]); + let pos = motion.vec2_at("position", mt); + let pos = if pos.x.is_nan() { Vec2::new(frame[2] / 2.0, frame[3] / 2.0) } else { pos }; + boxes.push((i, pos, b)); + } + if boxes.is_empty() { + return Err(bad("graphics.align", "the selected clips have no picture to align")); + } + let union = union_box(&boxes); + let group = align_delta(how, frame, union)?; + let mut moves = Vec::new(); + for (i, pos, b) in &boxes { + let (dx, dy) = match mode { + AlignTo::Frame => align_delta(how, frame, *b)?, + AlignTo::FrameGroup => group, + AlignTo::Selection => align_delta(how, union, *b)?, + }; + moves.extend(clips.get(*i).map(|c| (*c, Vec2::new(pos.x + dx, pos.y + dy)))); + } + s.edit_sequence("Align Clips", |q, _, _| { + for (c, pos) in &moves { + let Some((_, it)) = q.find_item_mut(*c) else { continue }; + let mt = at(it); + if let Some(prm) = it.effects.iter_mut().find(|e| e.effect == "motion").and_then(|e| e.params.get_mut("position")) { + prm.set_at(mt, ParamValue::Vec2(*pos)); + } + } + Ok(()) + })?; + Ok(json!({"clips": moves.iter().map(|(c, _)| c.0).collect::>()})) +} + +/// Graphics ▸ Align to Video Frame / Align to Video Frame as Group / Align to Selection. With video +/// clips selected instead of a graphic, the clips align (#1081). fn align_layers(s: &mut Session, p: &Value, how: &str, mode: AlignTo) -> Result { + let clips = align_target_clips(s, p); + if !clips.is_empty() { + return align_clips(s, &clips, how, mode); + } let clip = target_clip(s, p).ok_or_else(|| bad("graphics.align", "no graphic clip"))?; let layers = layers_p(s, clip, p)?; let (boxes, size) = layer_boxes(s, clip, &layers)?; @@ -669,7 +740,7 @@ fn has_graphics_in_seq(s: &Session) -> std::result::Result<(), String> { if graphic_clips(s).is_empty() { Err("the sequence has no graphics".into()) } else { Ok(()) } } -const ALIGN_PARAMS: &str = r#"{"clip":id?,"layers":[n]? (default: the selected layers)}"#; +const ALIGN_PARAMS: &str = r#"{"clip":id?,"layers":[n]? (default: the selected layers; selected video clips move by Motion Position)}"#; macro_rules! align_cmd { ($id:literal, $label:literal, $menu:expr, $how:literal, $mode:expr, $en:expr) => { @@ -787,12 +858,12 @@ fn menu_commands() -> Vec { Ok(json!({"clip": clip.0, "item": item.0})) }, ), - align_cmd!("graphics.alignFrame.left", "Left", F, "left", AlignTo::Frame, has_graphic), - align_cmd!("graphics.alignFrame.hcenter", "Center Horizontally", F, "hcenter", AlignTo::Frame, has_graphic), - align_cmd!("graphics.alignFrame.right", "Right", F, "right", AlignTo::Frame, has_graphic), - align_cmd!("graphics.alignFrame.top", "Top", F, "top", AlignTo::Frame, has_graphic), - align_cmd!("graphics.alignFrame.vcenter", "Center Vertically", F, "vcenter", AlignTo::Frame, has_graphic), - align_cmd!("graphics.alignFrame.bottom", "Bottom", F, "bottom", AlignTo::Frame, has_graphic), + align_cmd!("graphics.alignFrame.left", "Left", F, "left", AlignTo::Frame, has_graphic_or_clip), + align_cmd!("graphics.alignFrame.hcenter", "Center Horizontally", F, "hcenter", AlignTo::Frame, has_graphic_or_clip), + align_cmd!("graphics.alignFrame.right", "Right", F, "right", AlignTo::Frame, has_graphic_or_clip), + align_cmd!("graphics.alignFrame.top", "Top", F, "top", AlignTo::Frame, has_graphic_or_clip), + align_cmd!("graphics.alignFrame.vcenter", "Center Vertically", F, "vcenter", AlignTo::Frame, has_graphic_or_clip), + align_cmd!("graphics.alignFrame.bottom", "Bottom", F, "bottom", AlignTo::Frame, has_graphic_or_clip), align_cmd!("graphics.alignGroup.left", "Left", G, "left", AlignTo::FrameGroup, has_two_layers), align_cmd!("graphics.alignGroup.hcenter", "Center Horizontally", G, "hcenter", AlignTo::FrameGroup, has_two_layers), align_cmd!("graphics.alignGroup.right", "Right", G, "right", AlignTo::FrameGroup, has_two_layers), diff --git a/crates/engine/src/graphics_tests.rs b/crates/engine/src/graphics_tests.rs index 94589f91..e8893104 100644 --- a/crates/engine/src/graphics_tests.rs +++ b/crates/engine/src/graphics_tests.rs @@ -658,3 +658,34 @@ fn a_layer_saved_before_the_box_height_existed_can_still_get_one() { s.execute("graphics.set", json!({"clip": clip.0, "props": {"box_height": 100}})).unwrap(); assert_eq!(s.execute("graphics.list", json!({"clip": clip.0})).unwrap()["layers"][0]["box"], json!([500.0, 100.0])); } + +#[test] +fn align_to_frame_moves_selected_video_clips_by_position_only() { + // #1081 + let mut s = demo(); + let q = s.active_sequence().unwrap(); + let (fw, fh) = (q.settings.width as f64, q.settings.height as f64); + let it = q.video_tracks[0].items[0].clone(); + let (sw, _) = s.project.source_size(it.item).unwrap(); + let c = it.id.0; + assert!(!s.is_enabled("graphics.alignFrame.hcenter"), "nothing selected"); + s.execute("timeline.select", json!({"clips": [c]})).unwrap(); + s.execute("effects.setParam", json!({"clip": c, "effect": "motion", "param": "scale", "value": 50.0})).unwrap(); + s.execute("effects.setParam", json!({"clip": c, "effect": "motion", "param": "position", "value": [300.0, 200.0]})).unwrap(); + let pos = |s: &Session| { + let it = s.active_sequence().unwrap().find_item(ClipId(c)).unwrap().1.clone(); + let m = it.effect("motion").unwrap(); + (m.vec2_at("position", Tick::ZERO), m.f64_at("scale", Tick::ZERO)) + }; + s.execute("graphics.alignFrame.hcenter", json!({})).unwrap(); + let (p, scale) = pos(&s); + assert!((p.x - fw / 2.0).abs() < 1e-6 && (p.y - 200.0).abs() < 1e-6, "{p:?}"); + assert_eq!(scale, 50.0, "scale untouched"); + s.execute("graphics.alignFrame.left", json!({})).unwrap(); + assert!((pos(&s).0.x - sw as f64 * 0.25).abs() < 1e-6, "left edge on the frame's: {:?}", pos(&s).0); + s.execute("graphics.alignFrame.bottom", json!({})).unwrap(); + let h = s.project.source_size(it.item).unwrap().1 as f64; + assert!((pos(&s).0.y - (fh - h * 0.25)).abs() < 1e-6, "{:?}", pos(&s).0); + s.execute("edit.undo", json!({})).unwrap(); + assert!((pos(&s).0.y - 200.0).abs() < 1e-6, "one undo step per alignment"); +} From eb3afad32637aedd083240286bb4566381078c53 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:11:03 +0200 Subject: [PATCH 15/73] Transform shutter: use the sequence frame rate on adjustment layers (#1088) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adjustment layers render their effects without an FxEnv, so Transform's shutter fell back to 24 fps. In a 60 fps sequence a 360° shutter then spanned 2.5 frames, blurring one frame before the move starts and one after it ends. FxCtx now carries the sequence frame rate, set wherever a sequence is known, and Transform uses it. --- crates/gpu/src/fx_tests.rs | 2 +- crates/render/src/adjustment_tests.rs | 34 ++++++++++++++++++++++ crates/render/src/color_match.rs | 2 ++ crates/render/src/effects.rs | 5 ++++ crates/render/src/gpufx.rs | 1 + crates/render/src/lib.rs | 2 ++ crates/render/src/lumetri_hdr_tests.rs | 2 +- crates/render/src/lumetri_presets.rs | 1 + crates/render/src/plan.rs | 1 + crates/render/src/preview_tests.rs | 1 + crates/render/src/vfx/distort.rs | 3 +- crates/render/src/vfx/mod.rs | 1 + crates/render/src/vfx/tests.rs | 29 ++++++++++++++++++ crates/render/tests/median_radius.rs | 1 + crates/render/tests/noise_frame_cadence.rs | 1 + 15 files changed, 83 insertions(+), 3 deletions(-) diff --git a/crates/gpu/src/fx_tests.rs b/crates/gpu/src/fx_tests.rs index 2298bdad..f896a421 100644 --- a/crates/gpu/src/fx_tests.rs +++ b/crates/gpu/src/fx_tests.rs @@ -24,7 +24,7 @@ const EXACT: f32 = 1e-4; const FLIPS: f64 = 0.01; fn cx(t: Tick, px_scale: f32) -> FxCtx<'static> { - FxCtx { t, px_scale, seconds: 0.0, timecode: "", clip_name: "", project: None, env: None, working: filmcraft_color::WorkingSpace::Rec709 } + FxCtx { t, px_scale, seconds: 0.0, timecode: "", clip_name: "", project: None, env: None, frame_rate: 24.0, working: filmcraft_color::WorkingSpace::Rec709 } } fn effect(id: &str, params: &[(&str, ParamValue)]) -> EffectInstance { diff --git a/crates/render/src/adjustment_tests.rs b/crates/render/src/adjustment_tests.rs index 3d6eaa68..84d28509 100644 --- a/crates/render/src/adjustment_tests.rs +++ b/crates/render/src/adjustment_tests.rs @@ -184,3 +184,37 @@ fn nested_adjustment_layers_stay_in_their_sequence() { }; assert_eq!(img.get(5, 5), plain.get(5, 5)); } + +#[test] +fn transform_shutter_on_an_adjustment_layer_spans_one_sequence_frame() { + // A 60 fps sequence: at 360° the shutter is one 60 fps frame, so the frames just outside + // the move stay sharp (they were blurred with a 24 fps shutter, #1088). + use filmcraft_project::{Interpolation, Keyframe}; + let r60 = FrameRate::FPS_60; + let mut s = scene(2); + s.p.sequence_mut(s.seq).unwrap().settings.frame_rate = r60; + let red = matte(&mut s.p, &mut s.map, [1.0, 0.0, 0.0, 1.0]); + let below = place(&mut s.p, s.seq, 0, red); + clip(&mut s, below).effect_mut("motion").unwrap().params.get_mut("scale").unwrap().value = ParamValue::Float(25.0); + let adj = adjustment(&mut s.p, s.seq, 1); + fx(&mut s, adj, "transform"); + let key = |sec: f64, x: f64| { + let mut k = Keyframe::new(Tick::from_seconds_f64(sec), ParamValue::Vec2(filmcraft_geom::Vec2::new(x, 90.0))); + k.interp = Interpolation::Linear; + k + }; + let e = clip(&mut s, adj).effect_mut("transform").unwrap(); + e.params.get_mut("shutter_angle").unwrap().value = ParamValue::Float(360.0); + e.params.get_mut("position").unwrap().keyframes = vec![key(0.5, 160.0), key(1.0, 260.0)]; + let mut diff = |frame: i64| { + let mut at = |shutter: bool| { + clip(&mut s, adj).effect_mut("transform").unwrap().params.get_mut("shutter_override").unwrap().value = ParamValue::Bool(shutter); + render_sequence(&s.p, s.seq, r60.tick_of(frame), RenderOptions::default(), &s.map) + }; + let (sharp, blurred) = (at(false), at(true)); + sharp.px.iter().zip(&blurred.px).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max) + }; + assert!(diff(29) < 1e-4, "frame before the move is blurred"); + assert!(diff(61) < 1e-4, "frame after the move is blurred"); + assert!(diff(45) > 1e-2, "moving frame is sharp"); +} diff --git a/crates/render/src/color_match.rs b/crates/render/src/color_match.rs index 6d2c98b1..2356937c 100644 --- a/crates/render/src/color_match.rs +++ b/crates/render/src/color_match.rs @@ -192,6 +192,7 @@ fn graded_stats(img: &Image, base: &EffectInstance, x: &[f32; 10], skin_protect: clip_name: "", project: None, env: None, + frame_rate: 24.0, working: filmcraft_color::WorkingSpace::Rec709, }; apply(&mut g, &set_params(base, x), &cx); @@ -361,6 +362,7 @@ mod tests { clip_name: "", project: None, env: None, + frame_rate: 24.0, working: filmcraft_color::WorkingSpace::Rec709, }; apply(&mut g, &set_params(base, &x), &cx); diff --git a/crates/render/src/effects.rs b/crates/render/src/effects.rs index 733ee21e..ce6a6a1a 100644 --- a/crates/render/src/effects.rs +++ b/crates/render/src/effects.rs @@ -29,6 +29,8 @@ pub struct FxCtx<'a> { /// The clip's surroundings (other frames, other tracks, sequence geometry) for temporal, /// track-reading and reframing effects; `None` in isolated tests and adjustment layers. pub env: Option<&'a dyn crate::vfx::FxEnv>, + /// Sequence frame rate (shutter-angle motion blur); also known where `env` is `None`. + pub frame_rate: f64, /// The sequence's working colour space: Lumetri grades HDR (PQ / HLG) working spaces in their /// own signal, normalised to HDR White ([`filmcraft_color::GradeSpace`]). pub working: filmcraft_color::WorkingSpace, @@ -934,6 +936,7 @@ mod tests { clip_name: "x", project: None, env: None, + frame_rate: 24.0, working: filmcraft_color::WorkingSpace::Rec709, } } @@ -1529,6 +1532,7 @@ mod lumetri_cpu_parity_tests { clip_name: "", project: None, env: None, + frame_rate: 24.0, working: filmcraft_color::WorkingSpace::Rec709, }; @@ -1568,6 +1572,7 @@ mod lumetri_cpu_parity_tests { clip_name: "", project: None, env: None, + frame_rate: 24.0, working: filmcraft_color::WorkingSpace::Rec709, }; let render = |roundness: f64| { diff --git a/crates/render/src/gpufx.rs b/crates/render/src/gpufx.rs index 6876a6d9..c6efb88b 100644 --- a/crates/render/src/gpufx.rs +++ b/crates/render/src/gpufx.rs @@ -1113,6 +1113,7 @@ mod tests { clip_name: "", project: None, env: None, + frame_rate: 24.0, working: filmcraft_color::WorkingSpace::Rec709, }; // Both sides of each cutoff, including samples inside the former artificial fade. diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 22d6629f..b4bb1be9 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -229,6 +229,7 @@ pub(crate) fn render_seq_tracks(project: &Project, seq: &Sequence, t: Tick, opts clip_name: &item.name, project: Some(project), env: None, + frame_rate: seq.settings.frame_rate.as_f64(), working: seq.settings.color.working, }; for e in item.effects.iter().filter(|e| e.def().is_some_and(|d| !d.intrinsic)) { @@ -483,6 +484,7 @@ pub(crate) fn item_layer_ex( clip_name: &item.name, project: Some(project), env: Some(&env), + frame_rate: seq.settings.frame_rate.as_f64(), working: seq.settings.color.working, }; for e in item.effects.iter().filter(|e| e.def().is_some_and(|d| !d.intrinsic) && !filmcraft_project::graphic::is_layer(e)) { diff --git a/crates/render/src/lumetri_hdr_tests.rs b/crates/render/src/lumetri_hdr_tests.rs index f246e8a5..3a5c166d 100644 --- a/crates/render/src/lumetri_hdr_tests.rs +++ b/crates/render/src/lumetri_hdr_tests.rs @@ -11,7 +11,7 @@ use crate::effects::{FxCtx, apply}; const RW: f32 = REFERENCE_WHITE_NITS as f32; fn cx(working: WorkingSpace) -> FxCtx<'static> { - FxCtx { t: Tick::ZERO, px_scale: 1.0, seconds: 0.0, timecode: "", clip_name: "", project: None, env: None, working } + FxCtx { t: Tick::ZERO, px_scale: 1.0, seconds: 0.0, timecode: "", clip_name: "", project: None, env: None, frame_rate: 24.0, working } } fn lumetri(params: &[(&str, ParamValue)]) -> EffectInstance { diff --git a/crates/render/src/lumetri_presets.rs b/crates/render/src/lumetri_presets.rs index 4cc51984..84962310 100644 --- a/crates/render/src/lumetri_presets.rs +++ b/crates/render/src/lumetri_presets.rs @@ -228,6 +228,7 @@ pub fn thumbnail(preset: &LumetriPreset, w: usize, h: usize) -> Image { clip_name: "", project: None, env: None, + frame_rate: 24.0, working: filmcraft_color::WorkingSpace::Rec709, }; apply(&mut img, &preset.instance(), &cx); diff --git a/crates/render/src/plan.rs b/crates/render/src/plan.rs index 166b615b..223e714d 100644 --- a/crates/render/src/plan.rs +++ b/crates/render/src/plan.rs @@ -434,6 +434,7 @@ fn push_item( clip_name: &item.name, project: Some(project), env: None, + frame_rate: seq.settings.frame_rate.as_f64(), working: seq.settings.color.working, }; let ops: Option> = chain.iter().map(|e| FxOp::eval(e, &cx, lw, lh).filter(FxOp::gpu_ok)).collect(); diff --git a/crates/render/src/preview_tests.rs b/crates/render/src/preview_tests.rs index d13d5b34..22faba47 100644 --- a/crates/render/src/preview_tests.rs +++ b/crates/render/src/preview_tests.rs @@ -234,6 +234,7 @@ fn effect_costs() { clip_name: "x", project: None, env: None, + frame_rate: 24.0, working: filmcraft_color::WorkingSpace::Rec709, }; apply(&mut img, &e, &cx); diff --git a/crates/render/src/vfx/distort.rs b/crates/render/src/vfx/distort.rs index db22ed10..e01e2fc3 100644 --- a/crates/render/src/vfx/distort.rs +++ b/crates/render/src/vfx/distort.rs @@ -233,7 +233,7 @@ 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 fps = cx.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; @@ -247,6 +247,7 @@ pub fn transform_fx(img: &mut Image, e: &EffectInstance, cx: &FxCtx) { clip_name: cx.clip_name, project: cx.project, env: cx.env, + frame_rate: cx.frame_rate, working: cx.working, }; let anchor = crate::effects::point(e, "anchor", &cx2, im); diff --git a/crates/render/src/vfx/mod.rs b/crates/render/src/vfx/mod.rs index 9c7c966a..4633e497 100644 --- a/crates/render/src/vfx/mod.rs +++ b/crates/render/src/vfx/mod.rs @@ -103,6 +103,7 @@ impl FxEnv for ItemEnv<'_> { clip_name: &self.item.name, project: Some(self.project), env: None, + frame_rate: self.seq.settings.frame_rate.as_f64(), working: self.seq.settings.color.working, }; for e in &self.item.effects { diff --git a/crates/render/src/vfx/tests.rs b/crates/render/src/vfx/tests.rs index 6cd0e01b..6fabf754 100644 --- a/crates/render/src/vfx/tests.rs +++ b/crates/render/src/vfx/tests.rs @@ -17,6 +17,7 @@ fn cx() -> FxCtx<'static> { clip_name: "clip", project: None, env: None, + frame_rate: 24.0, working: filmcraft_color::WorkingSpace::Rec709, } } @@ -1154,3 +1155,31 @@ mod pipeline { assert!(e.px.iter().all(|v| v.is_finite())); } } + +#[test] +fn transform_shutter_uses_the_sequence_frame_rate_without_env() { + // An adjustment layer has no env: the shutter must still span one 60 fps frame at 360°, so + // the frames just before the motion starts and just after it ends stay sharp (#1088). + let mut e = inst("transform"); + set(&mut e, "shutter_override", ParamValue::Bool(true)); + setf(&mut e, "shutter_angle", 360.0); + let mut a = Keyframe::new(Tick::from_seconds_f64(1.0), ParamValue::Vec2(filmcraft_geom::Vec2::new(8.0, 16.0))); + a.interp = Interpolation::Linear; + let mut b = Keyframe::new(Tick::from_seconds_f64(2.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 mut off = e.clone(); + set(&mut off, "shutter_override", ParamValue::Bool(false)); + let delta = |frame: i32| { + let s = f64::from(frame) / 60.0; + let cx = FxCtx { t: Tick::from_seconds_f64(s), seconds: s, frame_rate: 60.0, ..cx() }; + max_diff(&run("transform", &off, &img, &cx), &run("transform", &e, &img, &cx)) + }; + for frame in [59, 121] { + assert!(delta(frame) < 1e-4, "frame {frame} is blurred outside the motion ({})", delta(frame)); + } + for frame in [60, 90, 120] { + assert!(delta(frame) > 1e-3, "frame {frame} moves but is sharp"); + } +} diff --git a/crates/render/tests/median_radius.rs b/crates/render/tests/median_radius.rs index b344a1dd..edf4fea0 100644 --- a/crates/render/tests/median_radius.rs +++ b/crates/render/tests/median_radius.rs @@ -17,6 +17,7 @@ fn cx() -> FxCtx<'static> { clip_name: "x", project: None, env: None, + frame_rate: 24.0, working: filmcraft_color::WorkingSpace::Rec709, } } diff --git a/crates/render/tests/noise_frame_cadence.rs b/crates/render/tests/noise_frame_cadence.rs index b36a6f39..bbdfca9c 100644 --- a/crates/render/tests/noise_frame_cadence.rs +++ b/crates/render/tests/noise_frame_cadence.rs @@ -18,6 +18,7 @@ fn render_noise(effect_id: &str, rate: FrameRate, frame: i64, amount: f64) -> Ve clip_name: "generated matte", project: None, env: None, + frame_rate: 24.0, working: filmcraft_color::WorkingSpace::Rec709, }; apply(&mut image, &effect, &context); From 4a75bc05faac1c4822c8379cb35d2e827b6a3481 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:11:07 +0200 Subject: [PATCH 16/73] Add my contributor entry --- contributors/people.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 04ed7243..d3f1b04d 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -77,3 +77,7 @@ sync_github_name = true real_name = "Valentin Klein" display_name = "Valentin Klein" sync_github_name = false + +[people.Silvertree2010] +display_name = "Netter" +sync_github_name = false From 52b36f9764f6e430632fd28bf171eb71df5e9b96 Mon Sep 17 00:00:00 2001 From: lloyd <34131158+LloydNicholson@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:01:33 +0200 Subject: [PATCH 17/73] codecs: import raw ADTS .aac files (#1291) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File ▸ Import and the Media Browser offer `.aac` (it is in AUDIO_EXTENSIONS), and Export ▸ AAC Audio writes ADTS `.aac` (#814), but no opener read them: `audio::opener` only accepted mp3/mp2/flac/ogg/aif by extension, so every `.aac` failed with "unrecognised format". `AudioFileSource` now decodes a standalone ADTS stream with our AAC decoder (ISO/IEC 13818-7 §6.2): frame after frame from the first header (after an ID3v2 tag), resyncing past damage, skipping frames with another configuration, keeping a frame that doesn't decode as silence, and upsampling HE-AAC to its output rate. Decoding stops with an error past 4 GiB of samples instead of allocating without bound. Tests encode a stereo tone with our encoder's ADTS output and import it (rate, channels, duration, level), and run truncated, bit-flipped and bad-length streams under catch_unwind. Co-Authored-By: Claude Opus 5.5 --- crates/codecs/src/audio.rs | 107 ++++++++++++++++++++++++++++--- crates/codecs/tests/adts_file.rs | 66 +++++++++++++++++++ 2 files changed, 164 insertions(+), 9 deletions(-) create mode 100644 crates/codecs/tests/adts_file.rs diff --git a/crates/codecs/src/audio.rs b/crates/codecs/src/audio.rs index f3fdf308..b6e58c11 100644 --- a/crates/codecs/src/audio.rs +++ b/crates/codecs/src/audio.rs @@ -814,6 +814,21 @@ impl AudioFileSource { }; return Ok(Self { info, rate, samples }); } + if ext == "aac" { + let (rate, samples) = decode_adts(&bytes)?.ok_or_else(|| CodecError::Decode(format!("{name}: no ADTS AAC frames")))?; + let frames = samples.first().map_or(0, Vec::len); + let info = MediaInfo { + name: name.to_string(), + kind: MediaKind::AudioOnly, + duration: Tick::from_units(frames as i64, rate as i64), + video: None, + audio_streams: vec![AudioStreamInfo { sample_rate: rate, channels: samples.len() as u32, codec: "AAC".into(), bits_per_sample: None }], + container: "ADTS".into(), + start_timecode: None, + file_size: Some(bytes.len() as u64), + }; + return Ok(Self { info, rate, samples }); + } let mss = MediaSourceStream::new(Box::new(std::io::Cursor::new(bytes.clone())), Default::default()); let mut hint = Hint::new(); hint.with_extension(&ext); @@ -871,20 +886,94 @@ impl AudioFileSource { } } +/// The length of an ID3v2 tag at the start of `bytes` (0 without one): a 10-byte header, a +/// syncsafe size, and a 10-byte footer when flagged (ID3v2.4 §3). +fn id3v2_len(bytes: &[u8]) -> usize { + match bytes { + [b'I', b'D', b'3', _, _, flags, s0, s1, s2, s3, ..] => { + let size = [*s0, *s1, *s2, *s3].iter().fold(0usize, |a, &b| (a << 7) | (b & 0x7F) as usize); + size.saturating_add(if flags & 0x10 != 0 { 20 } else { 10 }) + } + _ => 0, + } +} + +/// Most samples (all channels together) a standalone ADTS file decodes into memory: 4 GiB of f32, +/// about three hours of 48 kHz stereo. Longer files are refused rather than allocated. +const MAX_ADTS_SAMPLES: usize = 1 << 30; + +/// The length of the ADTS frame starting `b` (ISO/IEC 13818-7 §6.2: 12-bit sync word, layer `00`, +/// 13-bit `frame_length` including the header), when it starts with a header and fits in `b`. +fn adts_frame_len(b: &[u8]) -> Option { + let [0xFF, b1, _, b3, b4, b5, ..] = *b else { return None }; + if b1 & 0xF6 != 0xF0 { + return None; + } + let len = ((b3 as usize & 3) << 11) | (b4 as usize) << 3 | (b5 as usize) >> 5; + let hdr = if b1 & 1 != 0 { 7 } else { 9 }; + (len > hdr && len <= b.len()).then_some(len) +} + +/// A raw ADTS stream (`.aac`) decoded with our AAC decoder: (output sample rate, planar samples). +/// Frames follow each other from the first header (after an ID3v2 tag); damaged data is skipped +/// up to the next header, frames with another configuration than the first are skipped, and a +/// frame that does not decode keeps its duration as silence. HE-AAC is upsampled to its output +/// rate. `Ok(None)` when no frame decodes. +fn decode_adts(bytes: &[u8]) -> Result>)>> { + let mut frames: Vec<(Vec, &[u8])> = Vec::new(); + let mut p = id3v2_len(bytes); + while let Some(rest) = bytes.get(p..).filter(|r| r.len() >= 7) { + match adts_frame_len(rest).and_then(|len| Some((len, adts_split(rest.get(..len)?)?))) { + Some((len, f)) => { + frames.push(f); + p += len; + } + None => p += 1, + } + } + let Some(asc) = frames.first().map(|f| f.0.clone()) else { return Ok(None) }; + let Some(rate) = aac_output_rate(&asc, frames.iter().take(8).map(|f| f.1)) else { return Ok(None) }; + let mut dec = PacketDecoder::aac(&asc, rate)?; + let mut out: Vec> = Vec::new(); + let mut total = 0usize; + let mut last = 0usize; + for (cfg, payload) in frames { + if cfg != asc { + continue; + } + let decoded = dec.decode(payload, 0).ok().filter(|c| out.is_empty() || c.len() == out.len()); + match decoded { + Some(c) if out.is_empty() => { + last = c.first().map_or(0, Vec::len); + out = c; + } + Some(c) => { + last = c.first().map_or(0, Vec::len); + for (o, s) in out.iter_mut().zip(c) { + o.extend(s); + } + } + None => { + for o in out.iter_mut() { + o.resize(o.len() + last, 0.0); + } + } + } + total = total.saturating_add(last.saturating_mul(out.len())); + if total > MAX_ADTS_SAMPLES { + return Err(CodecError::Unsupported("AAC file too long to import as raw ADTS (over 4 GiB decoded): remux it to .m4a".into())); + } + } + Ok(out.first().is_some_and(|c| !c.is_empty()).then_some((rate, out))) +} + /// A raw MPEG audio Layer I / II stream (`.mp2`) decoded with our decoder: (layer, sample rate, /// planar samples). Frames follow each other from the first frame header (after an ID3v2 tag); /// damaged data is skipped up to the next frame header of the same layer and rate, and a frame /// with another channel count keeps its duration as silence. `None` when the stream does not start /// with a Layer I / II frame followed by another frame (or the end), e.g. MP3. fn decode_mpeg_layer12(bytes: &[u8]) -> Option<(u8, u32, Vec>)> { - let mut p = match bytes { - // ID3v2: 10-byte header, a syncsafe size, and a 10-byte footer when flagged - [b'I', b'D', b'3', _, _, flags, s0, s1, s2, s3, ..] => { - let size = [*s0, *s1, *s2, *s3].iter().fold(0usize, |a, &b| (a << 7) | (b & 0x7F) as usize); - size.saturating_add(if flags & 0x10 != 0 { 20 } else { 10 }) - } - _ => 0, - }; + let mut p = id3v2_len(bytes); let frame_at = |p: usize| bytes.get(p..).and_then(|b| filmcraft_mpa::parse_header(b).ok()).filter(|h| h.frame_bytes > 0); let first = frame_at(p)?; let next = p.saturating_add(first.frame_bytes); @@ -928,7 +1017,7 @@ impl MediaSource for AudioFileSource { pub fn opener(name: &str, bytes: Arc<[u8]>) -> Option> { let ext = name.rsplit('.').next().unwrap_or("").to_ascii_lowercase(); - if !matches!(ext.as_str(), "mp3" | "mp2" | "flac" | "ogg" | "oga" | "aif" | "aiff" | "aifc") { + if !matches!(ext.as_str(), "mp3" | "mp2" | "aac" | "flac" | "ogg" | "oga" | "aif" | "aiff" | "aifc") { return None; } Some(AudioFileSource::decode(name, bytes).map(|s| Arc::new(s) as SharedSource).map_err(Into::into)) diff --git a/crates/codecs/tests/adts_file.rs b/crates/codecs/tests/adts_file.rs new file mode 100644 index 00000000..9a7e95b3 --- /dev/null +++ b/crates/codecs/tests/adts_file.rs @@ -0,0 +1,66 @@ +//! Standalone ADTS `.aac` files (#1291): File ▸ Import offers them, and FilmCraft's own AAC export +//! writes them (#814), so they must open as audio. The streams are made with our AAC encoder, which +//! prefixes each access unit with an ADTS header (ISO/IEC 13818-7 §6.2). + +use std::sync::Arc; + +use filmcraft_aac::{Encoder, EncoderConfig}; + +const RATE: u32 = 48_000; + +/// `secs` of a 440 Hz tone at half scale in every channel, as an ADTS stream. +fn adts_tone(channels: usize, secs: u32) -> Vec { + let tone: Vec = (0..RATE * secs).map(|i| (std::f32::consts::TAU * 440.0 * i as f32 / RATE as f32).sin() * 0.5).collect(); + let planar: Vec<&[f32]> = (0..channels).map(|_| tone.as_slice()).collect(); + let mut enc = Encoder::new(EncoderConfig { adts: true, ..EncoderConfig::cbr(RATE, channels, 128_000) }).unwrap(); + let mut out = Vec::new(); + for au in enc.encode(&planar).into_iter().chain(enc.flush()) { + out.extend(au); + } + out +} + +fn rms(s: &[f32]) -> f32 { + (s.iter().map(|v| v * v).sum::() / s.len().max(1) as f32).sqrt() +} + +#[test] +fn adts_aac_files_import_as_audio() { + let src = filmcraft_codecs::open_bytes("tone.aac", Arc::from(adts_tone(2, 1))).unwrap(); + let info = src.info(); + let a = info.audio().unwrap(); + assert_eq!((a.sample_rate, a.channels), (RATE, 2)); + // One second plus the encoder's priming and the last frame's padding (at most two frames). + let secs = info.duration.seconds(); + assert!((1.0..=1.0 + 2048.0 / RATE as f64).contains(&secs), "duration {secs}"); + let buf = src.audio(RATE as i64 / 4, RATE as usize / 2, RATE).unwrap(); + for (c, ch) in buf.channels.iter().enumerate() { + let level = rms(ch); + assert!((level - 0.5 / 2f32.sqrt()).abs() < 0.05, "channel {c} rms {level}"); + } +} + +#[test] +fn damaged_adts_files_fail_without_panicking() { + let good = adts_tone(1, 1); + let mut cases: Vec> = (0..good.len().min(4096)).step_by(7).map(|n| good[..n].to_vec()).collect(); + for i in (0..good.len()).step_by(97) { + let mut b = good.clone(); + b[i] ^= 1 << (i % 8); + cases.push(b); + } + // A frame length of zero, and one running past the end of the file. + let mut zero = good.clone(); + zero[3] &= !3; + zero[4] = 0; + zero[5] &= 0x1F; + cases.push(zero); + let mut long = good[..64].to_vec(); + long[3] |= 3; + long[4] = 0xFF; + cases.push(long); + for (i, b) in cases.into_iter().enumerate() { + let r = std::panic::catch_unwind(|| filmcraft_codecs::open_bytes("damaged.aac", Arc::from(b)).map(|s| s.audio(0, 4800, RATE).is_ok())); + assert!(r.is_ok(), "case {i} panicked"); + } +} From d6df5c6abde5f0d029c626c24fd06c240a202ba1 Mon Sep 17 00:00:00 2001 From: lloyd <34131158+LloydNicholson@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:06:04 +0200 Subject: [PATCH 18/73] media: decode uncompressed and PackBits YCbCr TIFF stills (#1293) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TIFFs stored as YCbCr (PhotometricInterpretation 6) without JPEG compression failed with "does not support the color type Unknown(24)": the image crate reads YCbCr only inside JPEG compression. ffmpeg writes these whenever the source is YUV, so an ordinary frame grab (`ffmpeg -i clip.mp4 -frames:v 1 grab.tiff`) couldn't be imported. `still::ycbcr_tiff` decodes them from TIFF 6.0 (§21 YCbCr images, §20 ReferenceBlackWhite, §9 PackBits), reading the directory with filmcraft-dng's TIFF parser: 8-bit chunky data units with subsampling 1, 2 or 4 each way (the image padded to whole data units), converted with the file's YCbCrCoefficients and ReferenceBlackWhite (or the §21 defaults). Every size, offset and count is checked, the picture is capped at 2^28 pixels, and separate planes, tiles and other bit depths are an "unsupported" error. JPEG-compressed and RGB TIFFs still go to the image crate. Tests: hand-built files for data-unit placement (4:2:0), video-range reference levels with PackBits (4:4:4), sizes that aren't whole data units, and truncated/bit-flipped/hostile files under catch_unwind; an ffmpeg oracle compares ffmpeg-written 4:2:0, 4:2:2 and 4:4:4 TIFFs (95×63) with ffmpeg's own RGB decode (every channel within 6). Co-Authored-By: Claude Opus 5.5 --- crates/media/src/still.rs | 4 + crates/media/src/still/ycbcr_tiff.rs | 174 +++++++++++++++++++++++++++ crates/media/tests/tiff_ycbcr.rs | 156 ++++++++++++++++++++++++ 3 files changed, 334 insertions(+) create mode 100644 crates/media/src/still/ycbcr_tiff.rs create mode 100644 crates/media/tests/tiff_ycbcr.rs diff --git a/crates/media/src/still.rs b/crates/media/src/still.rs index f2825d08..debc3a9c 100644 --- a/crates/media/src/still.rs +++ b/crates/media/src/still.rs @@ -11,6 +11,7 @@ use crate::{FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource, Result, pub mod dng; pub mod exr; +mod ycbcr_tiff; /// Default still duration (Premiere's preference default is 5 seconds). pub const DEFAULT_STILL_DURATION: Tick = Tick(5 * TICKS_PER_SECOND); @@ -46,6 +47,9 @@ pub fn decode_raster(name: &str, bytes: &[u8]) -> Result { if crate::tga::sniff(name, bytes) { return crate::tga::decode(name, bytes); } + if ycbcr_tiff::sniff(bytes) { + return ycbcr_tiff::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(); diff --git a/crates/media/src/still/ycbcr_tiff.rs b/crates/media/src/still/ycbcr_tiff.rs new file mode 100644 index 00000000..74ab74cb --- /dev/null +++ b/crates/media/src/still/ycbcr_tiff.rs @@ -0,0 +1,174 @@ +//! Uncompressed and PackBits YCbCr TIFFs (TIFF 6.0 §21), as ffmpeg writes from YUV sources. The +//! image crate reads YCbCr only inside JPEG compression, so these are decoded here: 8-bit chunky +//! data units of any subsampling (1, 2 or 4 each way), converted to RGB with the file's +//! YCbCrCoefficients and ReferenceBlackWhite (§20), chroma repeated across each data unit. +//! +//! Every number comes from the file: sizes, strip offsets and counts are checked before use, the +//! picture is capped at [`MAX_PIXELS`], and PackBits output stops at the bytes the image needs. + +use filmcraft_dng::tiff::{Tiff, tags}; + +use super::Raster; +use crate::{MediaError, Result}; + +/// Largest picture decoded (1 GiB of RGBA), as for animated images. +const MAX_PIXELS: u64 = 1 << 28; + +const PHOTOMETRIC_YCBCR: u64 = 6; +const COMPRESSION_NONE: u64 = 1; +const COMPRESSION_PACKBITS: u64 = 32773; +const YCBCR_COEFFICIENTS: u16 = 529; +const YCBCR_SUBSAMPLING: u16 = 530; +const REFERENCE_BLACK_WHITE: u16 = 532; + +/// Whether `bytes` is a TIFF whose first image is uncompressed or PackBits YCbCr (the ones this +/// module decodes instead of the image crate). +pub fn sniff(bytes: &[u8]) -> bool { + let Ok((t, first)) = Tiff::new(bytes) else { return false }; + let Ok((ifd, _)) = t.read_ifd(first, 0) else { return false }; + let num = |tag| ifd.get(tag).and_then(|e| t.uint(e).ok()); + num(tags::PHOTOMETRIC) == Some(PHOTOMETRIC_YCBCR) && matches!(num(tags::COMPRESSION).unwrap_or(COMPRESSION_NONE), COMPRESSION_NONE | COMPRESSION_PACKBITS) +} + +/// Decode the first image of a YCbCr TIFF (see [`sniff`]) to RGBA. +pub fn decode(name: &str, bytes: &[u8]) -> Result { + let bad = |what: &str| MediaError::Decode(format!("{name}: YCbCr TIFF: {what}")); + let unsupported = |what: &str| MediaError::Unsupported(format!("{name}: YCbCr TIFF with {what}")); + let (t, first) = Tiff::new(bytes).map_err(|_| bad("not a TIFF"))?; + let (ifd, _) = t.read_ifd(first, 0).map_err(|_| bad("unreadable image directory"))?; + let uints = |tag: u16| -> Result>> { ifd.get(tag).map(|e| t.uints(e).map_err(|_| bad("unreadable field"))).transpose() }; + let one = |tag: u16| -> Result> { Ok(uints(tag)?.and_then(|v| v.first().copied())) }; + + let width = one(tags::IMAGE_WIDTH)?.ok_or_else(|| bad("no ImageWidth"))?; + let height = one(tags::IMAGE_LENGTH)?.ok_or_else(|| bad("no ImageLength"))?; + if width == 0 || height == 0 { + return Err(bad("empty image")); + } + if width.saturating_mul(height) > MAX_PIXELS { + return Err(unsupported("more than 268 million pixels")); + } + if one(tags::SAMPLES_PER_PIXEL)?.unwrap_or(1) != 3 { + return Err(unsupported("other than three samples per pixel")); + } + if uints(tags::BITS_PER_SAMPLE)?.unwrap_or_else(|| vec![1]).iter().any(|&b| b != 8) { + return Err(unsupported("other than 8 bits per sample")); + } + if one(tags::PLANAR_CONFIGURATION)?.unwrap_or(1) != 1 { + return Err(unsupported("separate planes")); + } + if ifd.get(tags::TILE_OFFSETS).is_some() { + return Err(unsupported("tiles")); + } + let compression = one(tags::COMPRESSION)?.unwrap_or(COMPRESSION_NONE); + let sub = uints(YCBCR_SUBSAMPLING)?.unwrap_or_else(|| vec![2, 2]); + let (sh, sv) = match sub.as_slice() { + [h, v, ..] if [1, 2, 4].contains(h) && [1, 2, 4].contains(v) => (*h, *v), + _ => return Err(bad("invalid YCbCrSubSampling")), + }; + // §21: the data units cover the image rounded up to whole units. + let (units_x, units_y) = (width.div_ceil(sh), height.div_ceil(sv)); + let unit_len = sh * sv + 2; + let need = usize::try_from(units_x.saturating_mul(units_y).saturating_mul(unit_len)).map_err(|_| bad("too large"))?; + + let offsets = uints(tags::STRIP_OFFSETS)?.ok_or_else(|| bad("no StripOffsets"))?; + let counts = uints(tags::STRIP_BYTE_COUNTS)?.ok_or_else(|| bad("no StripByteCounts"))?; + let mut units: Vec = Vec::with_capacity(need); + for (&off, &len) in offsets.iter().zip(&counts) { + if units.len() >= need { + break; + } + let strip = usize::try_from(off) + .ok() + .zip(usize::try_from(len).ok()) + .and_then(|(o, l)| bytes.get(o..o.checked_add(l)?)) + .ok_or_else(|| bad("strip outside the file"))?; + match compression { + COMPRESSION_NONE => units.extend_from_slice(strip.get(..need - units.len()).unwrap_or(strip)), + _ => unpackbits(strip, &mut units, need), + } + } + if units.len() < need { + return Err(bad("image data shorter than the image")); + } + + let coeffs = match ifd.get(YCBCR_COEFFICIENTS) { + Some(e) => t.floats(e).map_err(|_| bad("unreadable YCbCrCoefficients"))?, + None => vec![0.299, 0.587, 0.114], + }; + let [lr, lg, lb] = coeffs.get(..3).and_then(|c| <[f64; 3]>::try_from(c).ok()).ok_or_else(|| bad("invalid YCbCrCoefficients"))?; + if lg.abs() < 1e-6 { + return Err(bad("invalid YCbCrCoefficients")); + } + let rbw = match ifd.get(REFERENCE_BLACK_WHITE) { + Some(e) => t.floats(e).map_err(|_| bad("unreadable ReferenceBlackWhite"))?, + None => vec![0.0, 255.0, 128.0, 255.0, 128.0, 255.0], + }; + let [yb, yw, cbb, cbw, crb, crw] = rbw.get(..6).and_then(|c| <[f64; 6]>::try_from(c).ok()).ok_or_else(|| bad("invalid ReferenceBlackWhite"))?; + if (yw - yb).abs() < 1e-6 || (cbw - cbb).abs() < 1e-6 || (crw - crb).abs() < 1e-6 { + return Err(bad("invalid ReferenceBlackWhite")); + } + // §20: luma to 0–255, chroma to ±127 about its reference black; §21: back to R'G'B'. + let to_rgb = |y: u8, cb: u8, cr: u8| -> [u8; 3] { + let y = (f64::from(y) - yb) * 255.0 / (yw - yb); + let cb = (f64::from(cb) - cbb) * 127.0 / (cbw - cbb); + let cr = (f64::from(cr) - crb) * 127.0 / (crw - crb); + let r = cr * (2.0 - 2.0 * lr) + y; + let b = cb * (2.0 - 2.0 * lb) + y; + let g = (y - lb * b - lr * r) / lg; + [r, g, b].map(|v| v.round().clamp(0.0, 255.0) as u8) + }; + + let (w, h) = (width as usize, height as usize); + let (sh, sv, units_x) = (sh as usize, sv as usize, units_x as usize); + let mut rgba = vec![255u8; w * h * 4]; + for (i, unit) in units.chunks_exact(sh * sv + 2).take(need / (sh * sv + 2)).enumerate() { + let (ux, uy) = (i % units_x, i / units_x); + let (cb, cr) = (unit[sh * sv], unit[sh * sv + 1]); + for (k, &y) in unit[..sh * sv].iter().enumerate() { + let (x, row) = (ux * sh + k % sh, uy * sv + k / sh); + if x < w && row < h { + let at = (row * w + x) * 4; + rgba[at..at + 3].copy_from_slice(&to_rgb(y, cb, cr)); + } + } + } + Ok(Raster { width: width as u32, height: height as u32, rgba, has_alpha: false }) +} + +/// PackBits (TIFF 6.0 §9) from `src` appended to `out`, stopping at `limit` bytes of output or at +/// a run that the data ends inside. +fn unpackbits(src: &[u8], out: &mut Vec, limit: usize) { + let mut p = 0; + while out.len() < limit { + let Some(&n) = src.get(p) else { return }; + let n = n as i8; + p += 1; + if n >= 0 { + let len = n as usize + 1; + let Some(lit) = src.get(p..p + len) else { return }; + out.extend_from_slice(&lit[..len.min(limit - out.len())]); + p += len; + } else if n != -128 { + let Some(&b) = src.get(p) else { return }; + let len = (1 - n as isize) as usize; + out.extend(std::iter::repeat_n(b, len.min(limit - out.len()))); + p += 1; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn packbits_runs_literals_and_noops() { + let mut out = Vec::new(); + // literal 2, a no-op, a run of 3, then a literal cut short by the end of the data + unpackbits(&[1, 10, 11, 0x80, 0xFE, 7, 4, 1], &mut out, 100); + assert_eq!(out, [10, 11, 7, 7, 7]); + let mut capped = Vec::new(); + unpackbits(&[0xFE, 7, 0xFE, 8], &mut capped, 4); + assert_eq!(capped, [7, 7, 7, 8]); + } +} diff --git a/crates/media/tests/tiff_ycbcr.rs b/crates/media/tests/tiff_ycbcr.rs new file mode 100644 index 00000000..f6474db2 --- /dev/null +++ b/crates/media/tests/tiff_ycbcr.rs @@ -0,0 +1,156 @@ +//! YCbCr TIFF stills (#1293): uncompressed and PackBits files with PhotometricInterpretation 6, as +//! ffmpeg writes from YUV sources. The files are built here from TIFF 6.0 (§21 YCbCr images, §9 +//! PackBits), little-endian, one strip. + +use filmcraft_media::still::decode_raster; + +/// A YCbCr TIFF: `width` × `height`, subsampling `sub` (horizontal, vertical), `data` the strip +/// as stored (data units, already compressed when `compression` is 32773), plus `extra` entries +/// (tag, type, values). +fn ycbcr_tiff(width: u32, height: u32, sub: (u16, u16), compression: u16, data: &[u8], extra: &[(u16, u16, Vec)]) -> Vec { + let mut entries: Vec<(u16, u16, Vec)> = vec![ + (256, 4, vec![width]), + (257, 4, vec![height]), + (258, 3, vec![8, 8, 8]), + (259, 3, vec![u32::from(compression)]), + (262, 3, vec![6]), + (273, 4, vec![0]), // patched below + (277, 3, vec![3]), + (278, 4, vec![height]), + (279, 4, vec![data.len() as u32]), + (530, 3, vec![u32::from(sub.0), u32::from(sub.1)]), + ]; + entries.extend(extra.iter().cloned()); + entries.sort_by_key(|e| e.0); + let ifd_at = 8u32; + let ifd_len = 2 + 12 * entries.len() as u32 + 4; + let mut extra_data: Vec = Vec::new(); + let mut ifd: Vec = (entries.len() as u16).to_le_bytes().to_vec(); + let data_at = ifd_at + ifd_len; + let mut values_at = data_at + data.len() as u32; + for (tag, ty, vals) in &entries { + let vals = if *tag == 273 { vec![data_at] } else { vals.clone() }; + let mut bytes: Vec = Vec::new(); + for v in &vals { + match ty { + 3 => bytes.extend((*v as u16).to_le_bytes()), + 5 => bytes.extend(v.to_le_bytes()), // rationals are passed as num, den pairs + _ => bytes.extend(v.to_le_bytes()), + } + } + let count = if *ty == 5 { vals.len() / 2 } else { vals.len() }; + ifd.extend(tag.to_le_bytes()); + ifd.extend(ty.to_le_bytes()); + ifd.extend((count as u32).to_le_bytes()); + if bytes.len() <= 4 { + bytes.resize(4, 0); + ifd.extend(&bytes); + } else { + ifd.extend(values_at.to_le_bytes()); + values_at += bytes.len() as u32; + extra_data.extend(bytes); + } + } + ifd.extend(0u32.to_le_bytes()); + let mut out = b"II*\0".to_vec(); + out.extend(ifd_at.to_le_bytes()); + out.extend(ifd); + out.extend(data); + out.extend(extra_data); + out +} + +fn px(r: &filmcraft_media::still::Raster, x: u32, y: u32) -> [u8; 4] { + let i = ((y * r.width + x) * 4) as usize; + [r.rgba[i], r.rgba[i + 1], r.rgba[i + 2], r.rgba[i + 3]] +} + +fn near(a: [u8; 4], b: [u8; 4]) -> bool { + a.iter().zip(b).all(|(x, y)| x.abs_diff(y) <= 2) +} + +#[test] +fn subsampled_ycbcr_places_each_data_unit() { + // 4 × 2 at 2 × 2: two data units (four Y, then Cb, Cr). Left grey, right red (BT.601, the + // default YCbCrCoefficients, full range: the default ReferenceBlackWhite). + let data = [128, 128, 128, 128, 128, 128, 76, 76, 76, 76, 85, 255]; + let r = decode_raster("frame.tiff", &ycbcr_tiff(4, 2, (2, 2), 1, &data, &[])).unwrap(); + assert_eq!((r.width, r.height), (4, 2)); + for y in 0..2 { + assert!(near(px(&r, 0, y), [128, 128, 128, 255]) && near(px(&r, 1, y), [128, 128, 128, 255]), "{:?}", px(&r, 0, y)); + assert!(near(px(&r, 2, y), [254, 0, 0, 255]) && near(px(&r, 3, y), [254, 0, 0, 255]), "{:?}", px(&r, 2, y)); + } +} + +#[test] +fn packbits_ycbcr_with_video_range_reference_levels() { + // 2 × 1 at 1 × 1 (4:4:4): white then black in video range (16–235, chroma 128 ± 112), the + // ReferenceBlackWhite ffmpeg writes. PackBits: a literal run of 6 bytes. + let units = [235, 128, 128, 16, 128, 128]; + let mut data = vec![5u8]; + data.extend(units); + let rbw = vec![16, 1, 235, 1, 128, 1, 240, 1, 128, 1, 240, 1]; + let r = decode_raster("frame.tif", &ycbcr_tiff(2, 1, (1, 1), 32773, &data, &[(532, 5, rbw)])).unwrap(); + assert!(near(px(&r, 0, 0), [255, 255, 255, 255]), "{:?}", px(&r, 0, 0)); + assert!(near(px(&r, 1, 0), [0, 0, 0, 255]), "{:?}", px(&r, 1, 0)); +} + +#[test] +fn sizes_that_are_not_a_whole_number_of_data_units_are_padded() { + // 3 × 3 at 2 × 2: the data units cover 4 × 4 (TIFF 6.0 §21), the extra column and row dropped. + let unit = |y: u8| [y, y, y, y, 128, 128]; + let data: Vec = [unit(50), unit(100), unit(150), unit(200)].concat(); + let r = decode_raster("frame.tiff", &ycbcr_tiff(3, 3, (2, 2), 1, &data, &[])).unwrap(); + assert_eq!((r.width, r.height, r.rgba.len()), (3, 3, 36)); + assert!(near(px(&r, 0, 0), [50, 50, 50, 255]) && near(px(&r, 2, 0), [100, 100, 100, 255])); + assert!(near(px(&r, 0, 2), [150, 150, 150, 255]) && near(px(&r, 2, 2), [200, 200, 200, 255])); +} + +#[test] +fn damaged_ycbcr_tiffs_fail_without_panicking() { + let data: Vec = (0..4 * 4 / 4 * 6).map(|i| (i * 37 % 256) as u8).collect(); + let good = ycbcr_tiff(4, 4, (2, 2), 1, &data, &[]); + let mut cases: Vec> = (0..good.len()).map(|n| good[..n].to_vec()).collect(); + for i in 0..good.len() { + for bit in [0, 3, 7] { + let mut b = good.clone(); + b[i] ^= 1 << bit; + cases.push(b); + } + } + // A huge size, a zero subsampling factor, and a PackBits run past the strip. + cases.push(ycbcr_tiff(65_535, 65_535, (2, 2), 1, &data, &[])); + cases.push(ycbcr_tiff(4, 4, (0, 2), 1, &data, &[])); + cases.push(ycbcr_tiff(4, 4, (2, 2), 32773, &[0x81, 7], &[])); + for (i, b) in cases.into_iter().enumerate() { + assert!(std::panic::catch_unwind(|| decode_raster("damaged.tiff", &b).map(|r| r.rgba.len())).is_ok(), "case {i} panicked"); + } +} + +/// ffmpeg (an external oracle) writes YCbCr TIFFs from YUV frames; our decode must match ffmpeg's +/// own RGB decode of the same file. A smooth gradient, so the chroma upsampling (we repeat chroma +/// across a data unit) does not dominate; 95 × 63 so the data units do not line up. +#[test] +fn ffmpeg_ycbcr_tiffs_match_ffmpeg_decode() { + let Some(ff) = filmcraft_testkit::oracle::ffmpeg_or_skip("YCbCr TIFF oracle") else { return }; + let dir = filmcraft_testkit::fixtures::fixtures_dir("media/tiff_ycbcr"); + for pix_fmt in ["yuv420p", "yuv422p", "yuv444p"] { + let tiff = dir.join(format!("gradient_{pix_fmt}.tiff")); + let made = filmcraft_testkit::fixtures::generate(&tiff, |tmp| { + std::process::Command::new(&ff) + .args(["-y", "-v", "error", "-f", "lavfi", "-i", "gradients=size=95x63:seed=7", "-frames:v", "1", "-pix_fmt", pix_fmt]) + .arg(tmp) + .status() + .is_ok_and(|s| s.success()) + }); + let Some(tiff) = made else { continue }; + let out = std::process::Command::new(&ff).args(["-v", "error", "-i"]).arg(&tiff).args(["-f", "rawvideo", "-pix_fmt", "rgb24", "-"]).output().unwrap(); + let ours = decode_raster("gradient.tiff", &std::fs::read(&tiff).unwrap()).unwrap(); + assert_eq!((ours.width, ours.height), (95, 63), "{pix_fmt}"); + let theirs = out.stdout; + assert_eq!(theirs.len(), 95 * 63 * 3, "{pix_fmt}"); + let worst = + ours.rgba.as_chunks::<4>().0.iter().zip(theirs.as_chunks::<3>().0).flat_map(|(a, b)| (0..3).map(move |c| a[c].abs_diff(b[c]))).max().unwrap(); + assert!(worst <= 6, "{pix_fmt}: worst channel difference {worst}"); + } +} From f21434cf9fbcb42a7fe647cbbb305ae3393b2ba1 Mon Sep 17 00:00:00 2001 From: gyro Date: Mon, 12 Oct 2026 05:18:42 -0700 Subject: [PATCH 19/73] fix(ui): open the context menu on macOS Ctrl+Click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On macOS, AppKit delivers Ctrl+Click to the winit view as a primary mouse press carrying the Control modifier — rightMouseDown only fires for a real right button or a two-finger trackpad click. winit 0.30 (button 0 → Left) and egui-winit 0.36 (Left → PointerButton::Primary) pass it through untouched, so egui's secondary_clicked() never fires and no context menu opens. Context menus subscribe to egui's secondary click, so the translation is done once at the input boundary, as the platform convention requires: winit deliberately leaves Ctrl+Click handling to the application (rust-windowing/winit#4469, wontfix), and macOS apps translate it themselves (Firefox does the same in nsCocoaWindow.mm). raw_input_hook runs before egui begins its pass, when pointer click state is computed — the only seam where a rewritten event has the right effect. Primary presses carrying the Control modifier become Secondary presses; the press and its release are paired so releasing Control mid-click still closes as a context click and never leaks a stray primary click, and a plain press or PointerGone clears any stale pairing. Real right clicks and other buttons pass through unchanged; Windows and Linux keep Ctrl+Click as a selection modifier, and control-channel/automation input is exempt. Unit tests cover the transformation through egui's real pointer state: a rewritten Ctrl+Click makes pointer.secondary_clicked() true, plain clicks are untouched, and a stale pairing cannot corrupt the next click. --- crates/ui-egui/src/lib.rs | 132 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9a6393b8..76257f5f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -302,6 +302,12 @@ pub struct FilmcraftApp { deferred: Vec<(ControlRequest, f64)>, last_ui_time: f64, pub(crate) synthetic: Vec, + /// macOS: a Control+Click already translated to a secondary (context) click, so its release is + /// translated too even if Control is let go mid-click. AppKit delivers Control+Click to the + /// view as a *primary* press with the Control modifier (`rightMouseDown:` only fires for a + /// right button or two-finger click), so the app does the platform's translation itself. + #[cfg_attr(not(target_os = "macos"), allow(dead_code))] + mac_ctrl_click_armed: bool, /// 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)>, @@ -555,6 +561,7 @@ impl FilmcraftApp { deferred: Vec::new(), last_ui_time: 0.0, synthetic: Vec::new(), + mac_ctrl_click_armed: false, value_press: None, loudness: None, status_seen: (String::new(), 0.0), @@ -1937,9 +1944,44 @@ impl FilmcraftApp { } } +/// On macOS a Control+Click means "right click": AppKit delivers it to the view as a *primary* +/// press carrying the Control modifier (`rightMouseDown:` only fires for a real right button or +/// two-finger click), so the app translates it to a secondary click before egui computes pointer +/// state. `armed` pairs the press with its release, so the release is still a context click when +/// Control is let go mid-click. Called from [`eframe::App::raw_input_hook`], which runs before +/// egui's pass; only wired in on macOS, where Ctrl+Click is not a selection modifier. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn synthesize_mac_ctrl_click(armed: &mut bool, raw_input: &mut egui::RawInput) { + for event in &mut raw_input.events { + match event { + egui::Event::PointerButton { button, pressed, modifiers, .. } if *button == egui::PointerButton::Primary => { + if *pressed { + if modifiers.ctrl { + *button = egui::PointerButton::Secondary; + *armed = true; + } else { + // A plain primary press ends any pairing left open (eg. the pointer left + // the window mid-click, which egui also forgets). + *armed = false; + } + } else if *armed { + *button = egui::PointerButton::Secondary; + *armed = false; + } + } + egui::Event::PointerGone => *armed = false, + _ => {} + } + } +} + 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); + #[cfg(target_os = "macos")] + if !self.control_input_only { + synthesize_mac_ctrl_click(&mut self.mac_ctrl_click_armed, raw_input); + } if self.control_input_only { // Preserve screenshot responses and all viewport lifecycle/geometry metadata. raw_input.events.retain(|event| matches!(event, egui::Event::Screenshot { .. })); @@ -2210,6 +2252,96 @@ mod clipboard_key_tests { } } +#[cfg(test)] +mod mac_ctrl_click_tests { + use egui::{Event, Modifiers, PointerButton, RawInput}; + + fn click(button: PointerButton, pressed: bool, ctrl: bool) -> Event { + Event::PointerButton { pos: egui::pos2(40.0, 12.0), button, pressed, modifiers: if ctrl { Modifiers::CTRL } else { Modifiers::NONE } } + } + + fn buttons(raw: &mut RawInput) -> Vec<(PointerButton, bool)> { + raw.events + .iter() + .filter_map(|e| match e { + Event::PointerButton { button, pressed, .. } => Some((*button, *pressed)), + _ => None, + }) + .collect() + } + + /// AppKit delivers Control+Click as a primary press with the Control modifier; it must reach + /// egui as a secondary click so `secondary_clicked()` (the context menus) fires on macOS. + #[test] + fn ctrl_click_is_a_secondary_click() { + let mut raw = RawInput { events: vec![click(PointerButton::Primary, true, true), click(PointerButton::Primary, false, true)], ..Default::default() }; + let mut armed = false; + super::synthesize_mac_ctrl_click(&mut armed, &mut raw); + assert!(!armed); + assert_eq!(buttons(&mut raw), vec![(PointerButton::Secondary, true), (PointerButton::Secondary, false)]); + } + + /// End to end: the rewritten events are what egui's pointer state sees, so the context + /// menus (`secondary_clicked()`) fire on a Control+Click exactly like on a real right click. + #[test] + fn synthesized_click_fires_secondary_clicked() { + let ctx = egui::Context::default(); + let mut raw = RawInput { events: vec![click(PointerButton::Primary, true, true), click(PointerButton::Primary, false, true)], ..Default::default() }; + let mut armed = false; + super::synthesize_mac_ctrl_click(&mut armed, &mut raw); + let mut secondary = false; + // No renderer here: drop the pass output, clearing its texture deltas so epaint's drop + // guard does not panic on the font texture. + let mut out = ctx.run_ui(raw, |ctx| secondary = ctx.input(|i| i.pointer.secondary_clicked())); + out.textures_delta.clear(); + assert!(secondary); + } + + /// Releasing Control before the mouse button still closes the click as a context click; the + /// mouse release alone must not become a stray primary click. + #[test] + fn release_is_paired_to_the_ctrl_press() { + let mut raw = RawInput { + events: vec![ + click(PointerButton::Primary, true, true), + click(PointerButton::Primary, false, false), // Control already released + ], + ..Default::default() + }; + let mut armed = false; + super::synthesize_mac_ctrl_click(&mut armed, &mut raw); + assert!(!armed); + assert_eq!(buttons(&mut raw), vec![(PointerButton::Secondary, true), (PointerButton::Secondary, false)]); + } + + /// A plain click keeps its meaning (on macOS Cmd is the selection modifier, not Control). + #[test] + fn plain_click_is_untouched() { + let mut raw = RawInput { events: vec![click(PointerButton::Primary, true, false), click(PointerButton::Primary, false, false)], ..Default::default() }; + super::synthesize_mac_ctrl_click(&mut false, &mut raw); + assert_eq!(buttons(&mut raw), vec![(PointerButton::Primary, true), (PointerButton::Primary, false)]); + } + + /// A half-open pairing (pointer left the window mid-click) must not turn the next plain click + /// into a context click. + #[test] + fn new_plain_press_clears_a_stale_pairing() { + let mut raw = RawInput { + events: vec![ + click(PointerButton::Primary, true, true), + Event::PointerGone, + click(PointerButton::Primary, true, false), + click(PointerButton::Primary, false, false), + ], + ..Default::default() + }; + let mut armed = false; + super::synthesize_mac_ctrl_click(&mut armed, &mut raw); + assert!(!armed); + assert_eq!(buttons(&mut raw), vec![(PointerButton::Secondary, true), (PointerButton::Primary, true), (PointerButton::Primary, false),]); + } +} + #[cfg(test)] mod gpu_fallback_tests { use filmcraft_render::plan::{FramePlan, PlanLayer}; From 7fdccec8d7acf12529a28ab50a00ea1cbd703647 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 13 Oct 2026 00:39:09 +1100 Subject: [PATCH 20/73] Keep the project-panel sort arrow from covering a narrow column name. --- crates/ui-egui/src/panels/project_views.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels/project_views.rs b/crates/ui-egui/src/panels/project_views.rs index 951dd587..d908725a 100644 --- a/crates/ui-egui/src/panels/project_views.rs +++ b/crates/ui-egui/src/panels/project_views.rs @@ -526,10 +526,13 @@ pub fn list_view(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: &View hp.rect_filled(cr, 0.0, t.hover); } let g = hp.layout_no_wrap(crate::i18n::t(&c.name).to_string(), Tokens::ui(f - 0.5), t.text_dim); - let gw = g.size().x; - hp.with_clip_rect(cr.shrink2(vec2(4.0, 0.0)).intersect(hr)).galley(pos2(cr.min.x + 6.0, cr.center().y - g.size().y / 2.0), g, t.text_dim); + // The sort arrow sits in the right of the header. Leave it a gap so it does not cover the name (#1295). + let arrow = if sort.column == c.name { 14.0 } else { 0.0 }; + let label = cr.shrink2(vec2(4.0, 0.0)); + let label = egui::Rect::from_min_max(label.min, pos2((label.max.x - arrow).max(label.min.x), label.max.y)).intersect(hr); + hp.with_clip_rect(label).galley(pos2(cr.min.x + 6.0, cr.center().y - g.size().y / 2.0), g, t.text_dim); if sort.column == c.name { - let ax = (cr.min.x + 10.0 + gw).min(cr.max.x - 8.0); + let ax = cr.max.x - 8.0; let (a, b) = if sort.descending { (cr.center().y + 4.0, cr.center().y - 4.0) } else { (cr.center().y - 4.0, cr.center().y + 4.0) }; hp.line_segment([pos2(ax, b), pos2(ax, a)], Stroke::new(1.3, t.text_dim)); hp.line_segment([pos2(ax - 3.0, a + (b - a).signum() * 3.0), pos2(ax, a)], Stroke::new(1.3, t.text_dim)); From b5e05a6501b779a40bc1461a01e515812f0a7a14 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 13 Oct 2026 00:39:15 +1100 Subject: [PATCH 21/73] Register .fcproj as a document the macOS app can open from Finder. --- packaging/macos/Info.plist.in | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packaging/macos/Info.plist.in b/packaging/macos/Info.plist.in index e9d9dc3b..115be364 100644 --- a/packaging/macos/Info.plist.in +++ b/packaging/macos/Info.plist.in @@ -49,6 +49,24 @@ NSHumanReadableCopyright Copyright © the FilmCraft authors. MIT OR Apache-2.0. + + CFBundleDocumentTypes + + + CFBundleTypeName + FilmCraft Project + CFBundleTypeRole + Editor + LSHandlerRank + Owner + LSItemContentTypes + + ai.storyteller.filmcraft.project + + + + UTExportedTypeDeclarations From 6bc526b5e10ba92037b95dab80e27d1664064841 Mon Sep 17 00:00:00 2001 From: Bart Boonstra Date: Mon, 12 Oct 2026 16:28:00 +0200 Subject: [PATCH 22/73] Adjustment layers: a transition fades their effects in and out instead of cutting them in after it During a transition on its track an adjustment layer rendered as nothing, because it has no picture of its own: Cmd+D at its start left the effects off for the whole dissolve and then cut them in. In a transition the layer is now the frame below with its effects (opacity, masks and Motion region included), so a dissolve fades the effects. A fade from or to nothing keeps the layer's blend mode. Frames where a transition touches an adjustment layer render on the CPU. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/sequence_extras_tests.rs | 37 ++++++++ crates/render/src/adjustment_tests.rs | 84 ++++++++++++++++++ crates/render/src/lib.rs | 99 +++++++++++++--------- crates/render/src/plan.rs | 20 +++-- 4 files changed, 193 insertions(+), 47 deletions(-) diff --git a/crates/engine/src/sequence_extras_tests.rs b/crates/engine/src/sequence_extras_tests.rs index c3c4674a..73db823b 100644 --- a/crates/engine/src/sequence_extras_tests.rs +++ b/crates/engine/src/sequence_extras_tests.rs @@ -276,3 +276,40 @@ fn flash_cue_markers_round_trip() { s.execute("edit.undo", json!({})).unwrap(); assert!(s.active_sequence().unwrap().markers.iter().all(|m| m.kind != MarkerKind::FlashCue)); } + +/// Apply Video Transition (Cmd+D) at both ends of an adjustment layer with Lumetri Color fades the +/// grade in and out, as on a clip: halfway through each dissolve the picture is about halfway +/// between the original and the graded frame, not the original with a hard cut after it. +#[test] +fn cmd_d_on_an_adjustment_layer_fades_its_effects_in_and_out() { + let mut s = demo(); + let item = s.execute("file.newAdjustmentLayer", json!({"seconds": 4})).unwrap()["item"].as_u64().unwrap(); + let layer = s.execute("timeline.place", json!({"item": item, "track": "V3", "seconds": 1.5})).unwrap()["clips"][0].as_u64().unwrap(); + s.execute("effects.apply", json!({"clips": [layer], "effect": "lumetri"})).unwrap(); + s.execute("effects.setParam", json!({"clip": layer, "effect": "lumetri", "param": "exposure", "value": 2.0})).unwrap(); + s.execute("timeline.select", json!({"clips": [layer]})).unwrap(); + for edge in ["in", "out"] { + s.execute("sequence.applyVideoTransition", json!({"clip": layer, "edge": edge, "frames": 24})).unwrap(); + } + let transitions: Vec<_> = s.active_sequence().unwrap().video_tracks[2].transitions.clone(); + assert_eq!(transitions.len(), 2, "a dissolve at each end"); + let mean = |s: &Session, t: Tick| { + let img = s.render_program_at(0.25, t).unwrap(); + img.px.chunks(4).map(|c| f64::from(c[0] + c[1] + c[2])).sum::() / (img.w * img.h) as f64 + }; + for tr in &transitions { + let mid = tr.start + Tick(tr.duration.0 / 2); + let mf = mean(&s, mid); + // the original frame: the layer switched off; the graded frame: without the dissolves + s.execute("clip.enable", json!({"clips": [layer]})).unwrap(); + let m0 = mean(&s, mid); + s.execute("edit.undo", json!({})).unwrap(); + let ids: Vec = transitions.iter().map(|t| t.id.0).collect(); + s.execute("sequence.removeTransition", json!({"transitions": ids})).unwrap(); + let m1 = mean(&s, mid); + s.execute("edit.undo", json!({})).unwrap(); + assert!(m1 > m0 * 1.2, "the grade brightens the frame: {m0} → {m1}"); + let k = (mf - m0) / (m1 - m0); + assert!((0.25..0.75).contains(&k), "halfway through the dissolve the grade is about half on: {k:.3}"); + } +} diff --git a/crates/render/src/adjustment_tests.rs b/crates/render/src/adjustment_tests.rs index 3d6eaa68..fe7eb60a 100644 --- a/crates/render/src/adjustment_tests.rs +++ b/crates/render/src/adjustment_tests.rs @@ -184,3 +184,87 @@ fn nested_adjustment_layers_stay_in_their_sequence() { }; assert_eq!(img.get(5, 5), plain.get(5, 5)); } + +fn dissolve(s: &mut Scene, track: usize, from: Option, to: Option, start: i64) { + let tr = filmcraft_project::Transition { + id: filmcraft_project::TransitionId(900 + start as u64), + effect: find_effect("cross_dissolve").unwrap().instance(), + start: R.tick_of(start), + duration: R.tick_of(12), + from, + to, + align: Default::default(), + reverse: false, + }; + s.p.sequence_mut(s.seq).unwrap().video_tracks[track].transitions.push(tr); +} + +fn at(s: &Scene, frame: i64) -> [f32; 4] { + render_sequence(&s.p, s.seq, R.tick_of(frame), RenderOptions::default(), &s.map).get(160, 90) +} + +/// A dissolve at the start of an adjustment layer (Cmd+D) fades its effects in, and one at its +/// end fades them out, instead of leaving them off for the transition and cutting in after it. +#[test] +fn a_dissolve_fades_the_adjustment_layers_effects_in_and_out() { + let mut s = scene(2); + let red = matte(&mut s.p, &mut s.map, [1.0, 0.0, 0.0, 1.0]); + place(&mut s.p, s.seq, 0, red); + let adj = adjustment(&mut s.p, s.seq, 1); + fx(&mut s, adj, "invert"); + dissolve(&mut s, 1, None, Some(adj), 0); + dissolve(&mut s, 1, Some(adj), None, 36); + // fade in over frames 0..12: red → halfway → cyan + assert!(near(at(&s, 0), [1.0, 0.0, 0.0], 1e-3), "{:?}", at(&s, 0)); + assert!(near(at(&s, 3), [0.75, 0.25, 0.25], 1e-3), "a quarter in: {:?}", at(&s, 3)); + assert!(near(at(&s, 6), [0.5, 0.5, 0.5], 1e-3), "halfway in: {:?}", at(&s, 6)); + assert!(near(at(&s, 24), [0.0, 1.0, 1.0], 1e-3), "{:?}", at(&s, 24)); + // fade out over frames 36..48 + assert!(near(at(&s, 42), [0.5, 0.5, 0.5], 1e-3), "halfway out: {:?}", at(&s, 42)); + assert!(near(at(&s, 45), [0.75, 0.25, 0.25], 1e-3), "three quarters out: {:?}", at(&s, 45)); +} + +/// The layer's own opacity and blend mode still count during the fade. +#[test] +fn a_dissolve_on_an_adjustment_layer_keeps_its_opacity_and_blend_mode() { + let mut s = scene(2); + let red = matte(&mut s.p, &mut s.map, [1.0, 0.0, 0.0, 1.0]); + place(&mut s.p, s.seq, 0, red); + let adj = adjustment(&mut s.p, s.seq, 1); + fx(&mut s, adj, "invert"); + clip(&mut s, adj).effect_mut("opacity").unwrap().params.get_mut("opacity").unwrap().value = ParamValue::Float(50.0); + dissolve(&mut s, 1, None, Some(adj), 0); + // halfway through the fade, at 50 % opacity: a quarter of the way to cyan + assert!(near(at(&s, 6), [0.75, 0.25, 0.25], 1e-3), "{:?}", at(&s, 6)); + // Multiply (red × cyan = black) fades from red to black + let op = clip(&mut s, adj).effect_mut("opacity").unwrap(); + op.params.get_mut("opacity").unwrap().value = ParamValue::Float(100.0); + op.params.get_mut("blend").unwrap().value = + ParamValue::Choice(filmcraft_project::effect::BLEND_MODES.iter().position(|b| *b == "Multiply").unwrap() as u32); + assert!(near(at(&s, 6), [0.5, 0.0, 0.0], 1e-3), "{:?}", at(&s, 6)); + assert!(near(at(&s, 24), [0.0, 0.0, 0.0], 1e-3), "{:?}", at(&s, 24)); +} + +/// A frame where a transition touches an adjustment layer is composited on the CPU, which knows +/// how to fade one; the layered (GPU) plan does not. +#[test] +fn a_transition_with_an_adjustment_layer_is_not_planned_as_layers() { + let mut s = scene(2); + let red = matte(&mut s.p, &mut s.map, [1.0, 0.0, 0.0, 1.0]); + let blue = matte(&mut s.p, &mut s.map, [0.0, 0.0, 1.0, 1.0]); + place(&mut s.p, s.seq, 0, red); + // V2: a blue clip from 0 to 24, then an adjustment layer from 24, a dissolve centred on the cut + let b = place(&mut s.p, s.seq, 1, blue); + clip(&mut s, b).duration = R.tick_of(24); + let a = s.p.add_item("Adjustment Layer", Label::Lavender, ItemKind::AdjustmentLayer { width: 320, height: 180, rate: R, duration: R.tick_of(48) }, None); + let mut ti = s.p.make_track_item(a, TrackKind::Video, R.tick_of(24), TimeRange::new(Tick::ZERO, R.tick_of(24)), R).unwrap(); + ti.effects.insert(0, find_effect("invert").unwrap().instance()); + let ac = ti.id; + s.p.sequence_mut(s.seq).unwrap().video_tracks[1].items.push(ti); + dissolve(&mut s, 1, Some(b), Some(ac), 18); + let q = s.p.sequence(s.seq).unwrap(); + // frame 20 is still on the blue clip, but the dissolve into the adjustment layer shows + assert!(!crate::plan::layered_at(&s.p, q, R.tick_of(20))); + // halfway (frame 24): half blue, half the inverted red (cyan) + assert!(near(at(&s, 24), [0.0, 0.5, 1.0], 1e-3), "{:?}", at(&s, 24)); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 22d6629f..f40760c1 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -189,14 +189,25 @@ pub(crate) fn render_seq_tracks(project: &Project, seq: &Sequence, t: Tick, opts if let Some(tr) = track.transitions.iter().find(|tr| tr.range().contains(t)) { let a = tr.from.and_then(|id| track.item(id)).filter(|i| i.enabled); let b = tr.to.and_then(|id| track.item(id)).filter(|i| i.enabled); - let la = a - .and_then(|i| item_layer(project, seq, i, t, opts, sources, &tc)) - .map(|(img, op, _)| with_opacity(img, op)) - .unwrap_or_else(|| Image::new(w, h)); - let lb = b - .and_then(|i| item_layer(project, seq, i, t, opts, sources, &tc)) - .map(|(img, op, _)| with_opacity(img, op)) - .unwrap_or_else(|| Image::new(w, h)); + // An adjustment layer has no picture of its own: in a transition it is the frame below + // with its effects, so a dissolve fades the effects in or out. + let layer = |i: &TrackItem, below: &Image| -> Option<(Image, Blend)> { + if is_adjustment(project, i) { + return opts.effects.then(|| adjustment_layer(project, seq, i, t, opts, &tc, below)); + } + item_layer(project, seq, i, t, opts, sources, &tc).map(|(img, op, bl)| (with_opacity(img, op), bl)) + }; + let below = canvas.snapshot(); + let la = a.and_then(|i| layer(i, &below)); + let lb = b.and_then(|i| layer(i, &below)); + // a fade from or to nothing keeps the one layer's blend mode (an adjustment layer in + // Multiply ends the fade as it carries on); between two layers they mix normally + let bl = match (&la, &lb) { + (Some((_, x)), None) | (None, Some((_, x))) => *x, + _ => Blend::Normal, + }; + let la = la.map(|l| l.0).unwrap_or_else(|| Image::new(w, h)); + let lb = lb.map(|l| l.0).unwrap_or_else(|| Image::new(w, h)); let p = tr.progress(t) as f32; // Reverse plays the transition backwards (e.g. an iris closing on the outgoing clip) // while still going from A to B. @@ -205,46 +216,20 @@ pub(crate) fn render_seq_tracks(project: &Project, seq: &Sequence, t: Tick, opts } else { transitions::apply_scaled(&tr.effect, &la, &lb, p, opts.scale) }; - blend::composite(canvas.get(), &mixed, 1.0, Blend::Normal); + blend::composite(canvas.get(), &mixed, 1.0, bl); continue; } let Some(item) = track.item_at(t) else { continue }; if !item.enabled { continue; } - let pi = project.item(item.item); - if let Some(pi) = pi - && matches!(pi.kind, ItemKind::AdjustmentLayer { .. }) - { + if is_adjustment(project, item) { if !opts.effects { continue; } - let mt = item.effect_time_at(t); - let mut adjusted = canvas.snapshot(); - let cx = effects::FxCtx { - t: mt, - px_scale: opts.scale, - seconds: (t - item.start).seconds(), - timecode: &tc, - clip_name: &item.name, - project: Some(project), - env: None, - working: seq.settings.color.working, - }; - for e in item.effects.iter().filter(|e| e.def().is_some_and(|d| !d.intrinsic)) { - mask::apply_effect(&mut adjusted, e, &cx); - } - let (op, bl) = opacity_blend(item, mt); - // Adjustment layer opacity (and opacity masks, in sequence pixels) mix adjusted over original. - let mut out = canvas.snapshot(); - let mut adj = adjusted; - adj.scale_alpha(op); - mask::apply_opacity_masks(&mut adj, item, mt, opts.scale); - // Motion moves / scales the adjustment layer's frame: it only applies inside it. - if let Some(region) = adjustment_region(seq, item, project, mt, opts.scale, w, h) { - let cov: Vec = region.px.as_chunks::<4>().0.iter().map(|p| p[3]).collect(); - mask::scale_by(&mut adj, &cov); - } + let below = canvas.snapshot(); + let (adj, bl) = adjustment_layer(project, seq, item, t, opts, &tc, &below); + let mut out = below; blend::composite(&mut out, &adj, 1.0, bl); canvas.set(out); continue; @@ -279,6 +264,42 @@ pub(crate) fn render_seq_tracks(project: &Project, seq: &Sequence, t: Tick, opts canvas } +fn is_adjustment(project: &Project, item: &TrackItem) -> bool { + project.item(item.item).is_some_and(|pi| matches!(pi.kind, ItemKind::AdjustmentLayer { .. })) +} + +/// An adjustment layer as a layer over `below` (the composite of the tracks under it): `below` +/// with the layer's effects, faded by its opacity and opacity masks and limited to its +/// (Motion-transformed) frame, and the blend mode it mixes with. +fn adjustment_layer(project: &Project, seq: &Sequence, item: &TrackItem, t: Tick, opts: RenderOptions, tc: &str, below: &Image) -> (Image, Blend) { + let (w, h) = (below.w, below.h); + let mt = item.effect_time_at(t); + let mut adj = below.clone(); + let cx = effects::FxCtx { + t: mt, + px_scale: opts.scale, + seconds: (t - item.start).seconds(), + timecode: tc, + clip_name: &item.name, + project: Some(project), + env: None, + working: seq.settings.color.working, + }; + for e in item.effects.iter().filter(|e| e.def().is_some_and(|d| !d.intrinsic)) { + mask::apply_effect(&mut adj, e, &cx); + } + let (op, bl) = opacity_blend(item, mt); + // Adjustment layer opacity (and opacity masks, in sequence pixels) mix adjusted over original. + adj.scale_alpha(op); + mask::apply_opacity_masks(&mut adj, item, mt, opts.scale); + // Motion moves / scales the adjustment layer's frame: it only applies inside it. + if let Some(region) = adjustment_region(seq, item, project, mt, opts.scale, w, h) { + let cov: Vec = region.px.as_chunks::<4>().0.iter().map(|p| p[3]).collect(); + mask::scale_by(&mut adj, &cov); + } + (adj, bl) +} + /// Coverage of an adjustment layer's (Motion-transformed) frame in output pixels, or `None` when /// it covers the whole output. fn adjustment_region(seq: &Sequence, item: &TrackItem, project: &Project, mt: Tick, scale: f32, w: usize, h: usize) -> Option { diff --git a/crates/render/src/plan.rs b/crates/render/src/plan.rs index 166b615b..edeb4b7a 100644 --- a/crates/render/src/plan.rs +++ b/crates/render/src/plan.rs @@ -228,20 +228,24 @@ fn whole_frame_on_cpu(project: &Project, seq: &Sequence, t: Tick) -> bool { } /// 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 { +/// transition the compositor cannot mix itself is showing (a transition into or out of an +/// adjustment layer counts, also on the side of the cut where the other clip is). +pub(crate) fn layered_at(project: &Project, seq: &Sequence, t: Tick) -> bool { + let adjustment = |it: &TrackItem| project.item(it.item).is_some_and(|p| matches!(p.kind, ItemKind::AdjustmentLayer { .. })); for tr in &seq.video_tracks { if !tr.enabled { continue; } - if let Some(trn) = tr.transitions.iter().find(|x| x.range().contains(t)) - && !simple_transition(&trn.effect.effect) - && trn.effect.effect != "push" - { - return false; + if let Some(trn) = tr.transitions.iter().find(|x| x.range().contains(t)) { + if !simple_transition(&trn.effect.effect) && trn.effect.effect != "push" { + return false; + } + if [trn.from, trn.to].into_iter().flatten().filter_map(|c| tr.item(c)).any(adjustment) { + return false; + } } if let Some(it) = tr.item_at(t) - && project.item(it.item).is_some_and(|p| matches!(p.kind, ItemKind::AdjustmentLayer { .. })) + && adjustment(it) { return false; } From 3f61c036ffd53cb32f4771c87b7b7b13ea7813ad Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 16:36:10 +0200 Subject: [PATCH 23/73] codecs: MP4 frames stamped just after the frame grid show on their own frame (fixes #1282) Phone footage stamps its frames up to a millisecond after the frame grid (a first frame 34.1 ms long at 30 fps). The frame shown at a time is the last one starting at or before it, so a sequence frame asked at its exact start got the picture before: the clip ran one frame behind and showed frame 0 twice. The request is already rounded to the track timescale (#73, as WebM in #83), but at 90 kHz a millisecond is 90 units. When opening an MP4, the video samples of a file that is a constant rate gone slightly off its grid are put back on it: the grid starts at the first picture and steps by the nominal frame duration, and the file is snapped only when every picture is less than a quarter of a frame from its own grid point and no two pictures share a grid point. Anything further off is variable frame rate and keeps all its times; a picture one unit off is left where it is, since that can't be told from timescale rounding. Only the video sample times move: the audio, the first picture and the clip duration are unchanged. --- crates/codecs/src/mp4.rs | 61 +++++++++++++++ crates/codecs/src/rounded_pts_tests.rs | 101 +++++++++++++++++++++++++ 2 files changed, 162 insertions(+) diff --git a/crates/codecs/src/mp4.rs b/crates/codecs/src/mp4.rs index 9d6f0eaf..ddf0dc0e 100644 --- a/crates/codecs/src/mp4.rs +++ b/crates/codecs/src/mp4.rs @@ -347,6 +347,7 @@ impl Mp4Source { Some(Mp4Audio { track: i, state, starts, offset, preroll }) }) .collect(); + snap_to_frame_grid(&mut file, vtrack); 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 }) } @@ -666,6 +667,66 @@ pub fn opener(name: &str, bytes: Arc<[u8]>) -> Option) { + let Some(t) = vtrack.and_then(|i| file.tracks.get_mut(i)) else { return }; + let Some(first) = t.samples.iter().map(|s| s.pts).min() else { return }; + // The nominal duration: the median sample duration, averaged with the durations one unit from + // it. Those are the same duration rounded to the timescale (1501 and 1502 at 59.94 fps over + // 90 kHz): the median alone drifts half a unit a frame, enough to snap a short clip onto the + // wrong points. + let mut durs: Vec = t.samples.iter().map(|s| s.duration).collect(); + durs.sort_unstable(); + let Some(&median) = durs.get(durs.len() / 2) else { return }; + let near: Vec = durs.iter().filter(|&&d| d.abs_diff(median) <= 1).map(|&d| f64::from(d)).collect(); + let mean = near.iter().sum::() / near.len().max(1) as f64; + if mean <= 0.0 { + return; + } + let rate = FrameRate::from_f64(f64::from(t.timescale) / mean); + // one frame lasts `step_num / step_den` track units + let (step_num, step_den) = (i128::from(t.timescale) * i128::from(rate.den), i128::from(rate.num)); + if step_num <= 0 || step_den <= 0 { + return; + } + // a sample's grid index and its new time + let snap = |pts: i64| -> Option<(i128, i64)> { + let off = i128::from(pts) - i128::from(first); + // the nearest grid index, then its grid point (both rounded half up) + let n = (2 * off * step_den + step_num).div_euclid(2 * step_num); + let at = i128::from(first) + (2 * n * step_num + step_den).div_euclid(2 * step_den); + let off_grid = (i128::from(pts) - at).abs(); + if off_grid * 4 * step_den >= step_num { + return None; + } + Some((n, if off_grid <= 1 { pts } else { i64::try_from(at).ok()? })) + }; + let Some(snapped) = t.samples.iter().map(|s| snap(s.pts)).collect::>>() else { return }; + // Two pictures by one grid point: not a constant rate. Checked on the index, not the new time: + // one picture kept a unit after the point and another moved onto it would be distinct times in + // the wrong order. + let mut points: Vec = snapped.iter().map(|&(n, _)| n).collect(); + points.sort_unstable(); + points.dedup(); + if points.len() != snapped.len() { + return; + } + for (s, (_, p)) in t.samples.iter_mut().zip(snapped) { + s.pts = p; + } +} + /// Start the file at its first picture when its video track has no edit list (#714). Without an /// edit list the media timeline is the presentation, so a B-frame stream's first picture sits at its /// composition delay (two frames on an x264 MP4 written without `elst`, and on fragmented MP4s): diff --git a/crates/codecs/src/rounded_pts_tests.rs b/crates/codecs/src/rounded_pts_tests.rs index 169c7a83..8b4a7124 100644 --- a/crates/codecs/src/rounded_pts_tests.rs +++ b/crates/codecs/src/rounded_pts_tests.rs @@ -134,3 +134,104 @@ fn webm_with_1ms_timestamps_shows_every_frame_once() { fn mp4_with_a_1ms_timescale_shows_every_frame_once() { check("mov", mp4_1ms); } + +/// An MP4 at a 90 kHz timescale whose frames after the first start `late` units after the frame +/// grid, as phone footage does (issue #1282): the first sample lasts `late` units longer. +fn mp4_90k_late(rate: FrameRate, late: i64) -> Arc<[u8]> { + let mut w = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mov)).unwrap(); + let t = w.add_track(TrackConfig::new(SampleEntry::jpeg((BITS * BLOCK) as u16, BLOCK as u16), 90_000)).unwrap(); + let start = |k: usize| { + let grid = (k as i128 * 90_000 * rate.den as i128 + rate.num as i128 / 2) / rate.num as i128; + grid as i64 + if k > 0 { late } else { 0 } + }; + for k in 0..frame_count(rate) { + let duration = (start(k + 1) - start(k)) as u32; + w.write_sample(t, WriteSample { data: &picture(k), duration, composition_offset: 0, is_sync: true }).unwrap(); + } + w.finish().unwrap().into_inner().into() +} + +fn check_90k_late(late: i64) { + let mut failures = Vec::new(); + for rate in RATES { + let src = crate::open_bytes("synthetic.mov", mp4_90k_late(rate, late)).unwrap(); + for p in problems(src.as_ref(), rate) { + failures.push(format!("{rate}, frames {late}/90000 s late: {p}")); + } + } + assert!(failures.is_empty(), "mov:\n{}", failures.join("\n")); +} + +#[test] +fn mp4_with_frames_1ms_after_the_grid_shows_every_frame_once() { + check_90k_late(90); +} + +#[test] +fn mp4_with_frames_on_the_grid_shows_every_frame_once() { + check_90k_late(0); +} + +/// An MP4 at 90 kHz with these sample durations. +fn mp4_90k(durations: &[u32]) -> Arc<[u8]> { + let mut w = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mov)).unwrap(); + let t = w.add_track(TrackConfig::new(SampleEntry::jpeg((BITS * BLOCK) as u16, BLOCK as u16), 90_000)).unwrap(); + for (k, &duration) in durations.iter().enumerate() { + w.write_sample(t, WriteSample { data: &picture(k), duration, composition_offset: 0, is_sync: true }).unwrap(); + } + w.finish().unwrap().into_inner().into() +} + +/// Files that are not a constant rate gone slightly off its grid keep their own times: whatever is +/// asked (each frame's start, the unit before it, every 30 fps grid point), the frame shown is the +/// last one starting at or before it. +#[test] +fn mp4_variable_rate_and_on_grid_frame_times_stay_put() { + let cases: [(&str, Vec); 5] = [ + // 1/30 s and 1/20 s in turn + ("alternating", (0..60).map(|k| if k % 2 == 0 { 3000 } else { 4500 }).collect()), + // 30 fps, then every frame 0.4 frame later (one frame lasts 1.4 frames) + ("shifted", (0..80).map(|k| if k == 40 { 4200 } else { 3000 }).collect()), + // 30 fps with a frame shown for a fifth of a frame: it and the next are both near one grid point + ( + "stutter", + (0..80) + .map(|k| match k { + 40 => 600, + 41 => 5400, + _ => 3000, + }) + .collect(), + ), + // 30 fps with two frames by one grid point, one a unit after it and one five units after + ( + "one unit and five after one point", + (0..80) + .map(|k| match k { + 39 => 3001, + 40 => 4, + 41 => 2995, + _ => 3000, + }) + .collect(), + ), + // 59.94 fps on its grid, stamps truncated to the timescale (1501, 1502, …) + ("59.94 truncated", (0..120).map(|k: u64| ((k + 1) * 1_501_500 / 1000 - k * 1_501_500 / 1000) as u32).collect()), + ]; + let mut failures = Vec::new(); + for (name, durations) in cases { + let src = crate::open_bytes("synthetic.mov", mp4_90k(&durations)).unwrap(); + let starts: Vec = durations.iter().scan(0i64, |t, &d| Some(std::mem::replace(t, *t + i64::from(d)))).collect(); + let end = *starts.last().unwrap(); + let mut asked: Vec = starts.iter().flat_map(|&s| [s, s - 1]).filter(|&s| s >= 0).collect(); + asked.extend((0..).map(|k| k * 3000).take_while(|&t| t <= end)); + for t in asked { + let want = starts.iter().rposition(|&s| s <= t).unwrap(); + let got = index_of(&src.video_frame(FrameRequest::full(Tick::from_rational(t, 1, 90_000))).unwrap()); + if got != want { + failures.push(format!("{name}: at {t}/90000 s wanted frame {want}, got {got}")); + } + } + } + assert!(failures.is_empty(), "{}", failures[..failures.len().min(8)].join("\n")); +} From 528e525d6754705862a623428976c92c14defcf1 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 17:44:34 +0200 Subject: [PATCH 24/73] fix(shortcuts): Clear and reassign also drop a command's panel copy of its key (part of #1333) --- crates/engine/src/shortcuts.rs | 22 +++++++++++++++-- crates/engine/src/shortcuts_tests.rs | 36 ++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/shortcuts.rs b/crates/engine/src/shortcuts.rs index b2436298..22f502ea 100644 --- a/crates/engine/src/shortcuts.rs +++ b/crates/engine/src/shortcuts.rs @@ -737,7 +737,8 @@ 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| !(same_shortcut_command(&b.command, command) && b.context() == nb.context())); + let copies = if nb.panel.is_none() { self.app_chords(command, None) } else { Vec::new() }; + self.bindings.retain(|b| !(same_shortcut_command(&b.command, command) && (b.context() == nb.context() || is_copy(b, &copies)))); } let mut reassigned = Vec::new(); if !keep_conflicts { @@ -769,6 +770,7 @@ impl Shortcuts { let before = self.snapshot(); let n0 = self.bindings.len(); let twins = shortcut_twins(command); + let copies = if panel == Some(None) { self.app_chords(command, chord.as_ref()) } else { Vec::new() }; self.bindings.retain(|b| { let cmd = same_shortcut_command(&b.command, command); let key_ok = chord.as_ref().is_none_or(|c| b.chord().as_ref() == Some(c)); @@ -778,7 +780,7 @@ impl Shortcuts { let panel_ok = match &panel { None => true, Some(p) if p.is_none() && twins => true, - Some(p) => &b.panel == p, + Some(p) => &b.panel == p || is_copy(b, &copies), }; !(cmd && key_ok && panel_ok) }); @@ -916,6 +918,16 @@ impl Shortcuts { Ok(name) } + /// The application-wide keys of `command` (only `key`, when given). + fn app_chords(&self, command: &str, key: Option<&Chord>) -> Vec { + self.bindings + .iter() + .filter(|b| same_shortcut_command(&b.command, command) && b.panel.is_none()) + .filter_map(Binding::chord) + .filter(|c| key.is_none_or(|k| k == c)) + .collect() + } + // ---------------------------------------------------------------- queries pub fn for_command(&self, id: &str) -> Vec<&Binding> { @@ -1017,6 +1029,12 @@ fn same_shortcut_command(bound: &str, target: &str) -> bool { bound == target || (shortcut_twins(bound) && shortcut_twins(target)) } +/// A panel binding on one of the command's application keys (New Bin's Cmd+B in the Project +/// panel) is a copy of that shortcut, so editing the application shortcut edits it too (#1333). +fn is_copy(b: &Binding, app_chords: &[Chord]) -> bool { + b.panel.is_some() && b.chord().is_some_and(|c| app_chords.contains(&c)) +} + 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 c589086d..c6beec57 100644 --- a/crates/engine/src/shortcuts_tests.rs +++ b/crates/engine/src/shortcuts_tests.rs @@ -133,6 +133,42 @@ fn clear_close_sequence_also_clears_file_close() { assert_eq!(s.shortcuts.resolve(&w, None, Platform::current()).unwrap().command, "sequence.addEdit"); } +#[test] +fn clear_new_bin_frees_cmd_b_in_the_project_panel_too() { + // #1333: New Bin has Cmd+B application-wide and again in the Project panel. The editor's Clear + // sends panel Application, so only the first went; the Project panel kept Cmd+B. + let dir = temp_dir("new-bin"); + let mut s = Session::default(); + s.shortcuts.set_dir(&dir); + let b = Chord::parse("Cmd+B").unwrap(); + let p = Platform::current(); + assert_eq!(s.shortcuts.resolve(&b, Some("Project"), p).unwrap().command, "file.newBin"); + s.execute("shortcuts.clear", json!({"command": "file.newBin", "panel": "Application"})).unwrap(); + assert!(s.shortcuts.for_command("file.newBin").is_empty(), "{:?}", s.shortcuts.for_command("file.newBin")); + s.execute("shortcuts.set", json!({"command": "sequence.addEdit", "keys": "Cmd+B"})).unwrap(); + let mut t = Shortcuts::new(); + t.set_dir(&dir); + for sc in [&s.shortcuts, &t] { + assert_eq!(sc.resolve(&b, None, p).unwrap().command, "sequence.addEdit"); + assert_eq!(sc.resolve(&b, Some("Project"), p).unwrap().command, "sequence.addEdit"); + assert!(sc.for_command("file.newBin").is_empty()); + } + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn reassigning_new_bin_moves_its_project_panel_key_too() { + // #1333: assigning New Bin another key replaced Cmd+B application-wide only. + let mut s = Session::default(); + s.execute("shortcuts.set", json!({"command": "file.newBin", "keys": "Cmd+Alt+B"})).unwrap(); + let keys: Vec<(&str, Option<&str>)> = s.shortcuts.for_command("file.newBin").iter().map(|b| (b.keys.as_str(), b.panel.as_deref())).collect(); + assert_eq!(keys, [("Cmd+Alt+B", None)]); + assert!(s.shortcuts.resolve(&Chord::parse("Cmd+B").unwrap(), Some("Project"), Platform::Mac).is_none()); + // a panel key of its own (History ▸ Left for Undo) is not a copy of the application one: it stays + s.execute("shortcuts.set", json!({"command": "edit.undo", "keys": "Cmd+Alt+Z"})).unwrap(); + assert!(s.shortcuts.for_command("edit.undo").iter().any(|b| b.panel.as_deref() == Some("History") && b.keys == "Left")); +} + #[test] fn panel_shortcuts_override_application_ones_with_focus() { let s = Session::default(); From dadbfe0bf8db4f83c85c6817a9cd4bad36b77b98 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 18:17:42 +0200 Subject: [PATCH 25/73] fix(masks): hide the mask handles while the clip is not selected (fixes #1341) --- crates/ui-egui/src/panels/masks.rs | 7 ++++++- crates/ui-egui/tests/masks_ui.rs | 31 ++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/masks.rs b/crates/ui-egui/src/panels/masks.rs index c741a1d3..61ea2b0f 100644 --- a/crates/ui-egui/src/panels/masks.rs +++ b/crates/ui-egui/src/panels/masks.rs @@ -225,13 +225,18 @@ fn inside(pts: &[Vec2], p: Vec2) -> bool { w != 0 } -/// Mask editing on the Program monitor (selected mask) and the pen tool. +/// Mask editing on the Program monitor (selected mask, while its clip is selected, as for the +/// transform box) and the pen tool. pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, frame: (u32, u32)) { if app.ui.mask_pen.is_some() { pen_overlay(app, ui, pic, frame); return; } let Some(sel) = app.session.state.selected_mask else { return }; + // (the mask stays selected: its handles come back with its clip, #1341) + if !app.session.state.selection.contains(&sel.clip) { + return; + } let Some(seq) = app.session.active_sequence() else { return }; let ph = app.session.playhead(); let Some((_, it)) = seq.find_item(sel.clip) else { return }; diff --git a/crates/ui-egui/tests/masks_ui.rs b/crates/ui-egui/tests/masks_ui.rs index 813d9eac..cb39219c 100644 --- a/crates/ui-egui/tests/masks_ui.rs +++ b/crates/ui-egui/tests/masks_ui.rs @@ -77,6 +77,10 @@ impl Driver { 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() } @@ -216,6 +220,33 @@ fn create_and_edit_masks_on_the_monitor() { d.shot("mask-tracked"); } +/// The selected mask's outline and handles are drawn on the Program monitor only while its clip +/// is selected, as Motion's box is: selecting another clip, or none, hides them (#1341). +#[test] +fn mask_handles_follow_the_clip_selection() { + let mut d = Driver::demo(); + let seq = d.exec("sequence.inspect", json!({})); + let items = seq["video"][0]["items"].as_array().unwrap().clone(); + let (id, start, dur) = (items[0]["clip"].as_u64().unwrap(), items[0]["start"].as_i64().unwrap(), items[0]["duration"].as_i64().unwrap()); + let other = items[1]["clip"].as_u64().unwrap(); + d.exec("playhead.set", json!({"time": start + dur / 2})); + d.exec("timeline.select", json!({"clips": [id]})); + d.exec("masks.add", json!({"clip": id, "effect": "opacity", "shape": "ellipse"})); + d.frames(3); + assert!(d.has("program.mask.vertex.0"), "handles of the selected clip's mask"); + d.shot("mask-clip-selected"); + for clips in [json!([other]), json!([])] { + d.exec("timeline.select", json!({"clips": clips})); + d.frames(3); + assert!(!d.has("program.mask.vertex.0"), "no handles with {clips} selected"); + assert!(!d.has("program.mask.body"), "no outline with {clips} selected"); + d.shot(&format!("mask-clip-deselected-{}", clips.as_array().map_or(0, Vec::len))); + } + d.exec("timeline.select", json!({"clips": [id]})); + d.frames(3); + assert!(d.has("program.mask.vertex.0"), "the handles come back with the clip"); +} + /// 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] From 67f1ea1ae3cb4ace764c0c2c25ef766044ff2a2d Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 18:28:53 +0200 Subject: [PATCH 26/73] render: Sharpen stays visible at reduced playback resolution (fixes #1351) --- crates/render/src/gpufx.rs | 4 +- crates/render/tests/sharpen_preview_res.rs | 65 ++++++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 crates/render/tests/sharpen_preview_res.rs diff --git a/crates/render/src/gpufx.rs b/crates/render/src/gpufx.rs index 6876a6d9..101575e7 100644 --- a/crates/render/src/gpufx.rs +++ b/crates/render/src/gpufx.rs @@ -355,7 +355,9 @@ impl FxOp { } } "sharpen" => { - let r = cx.px_scale.max(0.35); + // below a sigma of ~0.58 the three boxes all round to radius 0 and the mask is a + // no-op, so Sharpen vanished at 1/2 playback resolution and below (#1351) + let r = cx.px_scale.max(0.6); let (rx, ry) = gaussian_boxes(w, h, r, r); FxOp::Unsharp { rx, ry, amount: f(e, "amount", cx) / 100.0, threshold: 0.0 } } diff --git a/crates/render/tests/sharpen_preview_res.rs b/crates/render/tests/sharpen_preview_res.rs new file mode 100644 index 00000000..d9d0c47a --- /dev/null +++ b/crates/render/tests/sharpen_preview_res.rs @@ -0,0 +1,65 @@ +//! Sharpen must stay visible at reduced playback resolution (#1351). +//! +//! Playback at 1/2 renders with `px_scale` 0.5. Sharpen's blur radius scaled with it to a sigma the +//! three-box Gaussian rounds to zero-width boxes, so the unsharp mask subtracted the image from +//! itself and the effect vanished while playing, though it showed on the paused (Full) frame. + +use filmcraft_project::{ParamValue, find_effect}; +use filmcraft_render::effects::{FxCtx, apply}; +use filmcraft_render::image::Image; +use filmcraft_time::Tick; + +fn cx(px_scale: f32) -> FxCtx<'static> { + FxCtx { + t: Tick::ZERO, + px_scale, + seconds: 0.0, + timecode: "00:00:01:00", + clip_name: "x", + project: None, + env: None, + working: filmcraft_color::WorkingSpace::Rec709, + } +} + +/// Dark left half, light right half: the edge sits between x = 7 and x = 8. +fn edge() -> Image { + let (w, h) = (16usize, 8usize); + let mut img = Image::filled(w, h, [0.25, 0.25, 0.25, 1.0]); + for y in 0..h { + for x in w / 2..w { + let i = (y * w + x) * 4; + img.px[i..i + 3].copy_from_slice(&[0.75, 0.75, 0.75]); + } + } + img +} + +fn sharpened(px_scale: f32) -> Image { + let mut img = edge(); + let mut e = find_effect("sharpen").expect("sharpen effect").instance(); + e.params.get_mut("amount").expect("amount").value = ParamValue::Float(100.0); + apply(&mut img, &e, &cx(px_scale)); + img +} + +#[test] +fn sharpen_shows_at_half_and_quarter_playback_resolution() { + let plain = edge(); + for px_scale in [0.5, 0.25] { + let img = sharpened(px_scale); + let (dark, light) = (img.get(7, 4)[0], img.get(8, 4)[0]); + assert!(dark < plain.get(7, 4)[0] - 0.01, "px_scale {px_scale}: the dark side of the edge should darken (got {dark})"); + assert!(light > plain.get(8, 4)[0] + 0.01, "px_scale {px_scale}: the light side of the edge should brighten (got {light})"); + } +} + +#[test] +fn sharpen_at_full_resolution_is_unchanged() { + // values rendered before the fix: the Full (paused) frame must not change + let img = sharpened(1.0); + for (x, want) in [(6, 0.25), (7, 0.25 - 1.0 / 6.0), (8, 0.75 + 1.0 / 6.0), (9, 0.75)] { + let got = img.get(x, 4)[0]; + assert!((got - want).abs() < 1e-5, "x {x}: got {got}, want {want}"); + } +} From a6a7f95579d320b9e6f1731f1264feeaecf83d33 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:36:19 +0200 Subject: [PATCH 27/73] Docs: update the progress docs for this change --- ROADMAP.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 7f3962fd..3a313137 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -106,6 +106,7 @@ Ranked; detail and "done when" in [docs/roadmap.md](docs/roadmap.md) and [docs/g ## Progress log +- **2026-10-12:** Transform's motion-blur shutter on adjustment layers uses the sequence frame rate instead of 24 fps, so a 360° shutter in a 60 fps sequence spans one frame, not 2.5 (#1088) - **2026-10-10 (changed media):** Settings ▸ Media ▸ Automatically refresh growing files works: media files that change on disk while the project is open (a recording still being written, a render written over the file a clip uses) are read again every *Refresh growing files every* seconds and as soon as the window comes back to the front: the new length, picture and identity, without an undo step (`media.refreshChanged`, `media_watch`). - **2026-10-10 (re-measure):** full parity re-measure against Premiere Pro 26.5.2: menu label diff 338 / 366 (92%), installed bundle listings (10 UI languages, exporter and codec frameworks), 188 open issues. Breadth ~86% (file formats now counted), ready ~55%, stage **alpha**; progress docs reorganized per the craftrules standard ([target-app-parity](docs/target-app-parity.md), [gaps](docs/gaps.md), [roadmap](docs/roadmap.md), [codec](docs/codec-parity.md), [file-format](docs/file-format-parity.md), [hardware](docs/hardware-parity.md), [UI](docs/ui-parity.md), [localization](docs/localization-parity.md)). - **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). @@ -164,6 +165,7 @@ Ranked; detail and "done when" in [docs/roadmap.md](docs/roadmap.md) and [docs/g | Date | Change | Summary | |---|---|---| +| 2026-10-12 | trivial | Progress log: Transform shutter on adjustment layers (#1088) | | 2026-10-11 | minor | Language table: German ships the menus (#630, 10.6%) | | 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 | From 44baf6c0f48528f6a3dc50c43b4cb9e158410492 Mon Sep 17 00:00:00 2001 From: jahvari <75337667+jahvari@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:43:18 -0500 Subject: [PATCH 28/73] WIN-03: Make AVI oracle file reads portable Co-Authored-By: Codex (GPT-6) --- crates/avi/tests/ffmpeg_oracle.rs | 49 ++++++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/crates/avi/tests/ffmpeg_oracle.rs b/crates/avi/tests/ffmpeg_oracle.rs index 7f1b1662..f3364b65 100644 --- a/crates/avi/tests/ffmpeg_oracle.rs +++ b/crates/avi/tests/ffmpeg_oracle.rs @@ -122,7 +122,7 @@ fn opendml_file_past_1_gb() { ]; let Some(path) = fixture("large_opendml.avi", &args) else { return }; let file = std::fs::File::open(&path).unwrap(); - let src = FileSource(file); + let src = FileSource(std::sync::Mutex::new(file)); let f = open(&src).unwrap(); assert_eq!(f.index, IndexSource::OpenDml); assert_eq!(f.streams[0].chunks.len(), packets(&path, 0)); @@ -139,13 +139,54 @@ fn opendml_file_past_1_gb() { } /// A file on disk as a byte source, read where asked. -struct FileSource(std::fs::File); +struct FileSource(std::sync::Mutex); impl filmcraft_avi::ByteSource for FileSource { fn len(&self) -> u64 { - self.0.metadata().map(|m| m.len()).unwrap_or(0) + self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).metadata().map(|m| m.len()).unwrap_or(0) } fn read_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> { - std::os::unix::fs::FileExt::read_exact_at(&self.0, buf, offset) + use std::io::{Read, Seek, SeekFrom}; + if buf.is_empty() { + return Ok(()); + } + let mut file = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + file.seek(SeekFrom::Start(offset))?; + file.read_exact(buf) } } + +#[test] +fn file_source_reads_exact_offsets_and_reports_eof() { + use filmcraft_avi::ByteSource as _; + use std::io::Write as _; + + let path = filmcraft_testkit::fixtures::temp_path(&filmcraft_testkit::fixtures_dir("avi").join("read-at-test.bin")); + let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&path).unwrap(); + file.write_all(b"0123456789abcdef").unwrap(); + drop(file); + let source = FileSource(std::sync::Mutex::new(std::fs::File::open(&path).unwrap())); + assert_eq!(source.len(), 16); + let mut bytes = [0; 4]; + source.read_at(7, &mut bytes).unwrap(); + assert_eq!(&bytes, b"789a"); + source.read_at(1, &mut bytes).unwrap(); + assert_eq!(&bytes, b"1234"); + assert_eq!(source.read_at(15, &mut bytes).unwrap_err().kind(), std::io::ErrorKind::UnexpectedEof); + assert!(source.read_at(u64::MAX, &mut bytes).is_err()); + source.read_at(u64::MAX, &mut []).unwrap(); + std::thread::scope(|scope| { + for (offset, expected) in [(0, b"0123"), (8, b"89ab")] { + let source = &source; + scope.spawn(move || { + for _ in 0..64 { + let mut bytes = [0; 4]; + source.read_at(offset, &mut bytes).unwrap(); + assert_eq!(&bytes, expected); + } + }); + } + }); + drop(source); + std::fs::remove_file(path).unwrap(); +} From c580406234562702822f3d89314b0b877036e4d1 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 18:45:39 +0200 Subject: [PATCH 29/73] fix(masks): Delete / Backspace and Cmd+Z take back the last pen point while drawing (fixes #1345) --- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/panels/masks.rs | 27 ++++++++++++ crates/ui-egui/tests/masks_ui.rs | 70 ++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9a6393b8..01d27eb5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1453,6 +1453,7 @@ 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!({})); } + panels::masks::pen_take_back(self, ctx, text_focused); // 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 diff --git a/crates/ui-egui/src/panels/masks.rs b/crates/ui-egui/src/panels/masks.rs index c741a1d3..29749889 100644 --- a/crates/ui-egui/src/panels/masks.rs +++ b/crates/ui-egui/src/panels/masks.rs @@ -565,6 +565,33 @@ pub fn draft_path(points: &[[f64; 4]]) -> MaskPath { } } +/// Delete / Backspace and Cmd+Z take back the last pen point while a mask is drawn (#1345). The +/// draft is outside the undo history: run before the shortcuts, so the keys reach neither Clear +/// (of the clip being masked) nor Undo (of the previous project edit). Taking back the last point +/// drops the draft. +pub(crate) fn pen_take_back(app: &mut FilmcraftApp, ctx: &egui::Context, text_focused: bool) { + let Some(d) = app.ui.mask_pen.as_mut() else { return }; + let n = ctx.input_mut(|i| { + // Shift+Cmd+Z is Redo: consume_key ignores an extra Shift + let redo = i.events.iter().any(|e| matches!(e, egui::Event::Key { key: egui::Key::Z, pressed: true, modifiers: m, .. } if m.shift)); + let undo = if redo { 0 } else { i.count_and_consume_key(egui::Modifiers::COMMAND, egui::Key::Z) }; + let clear = if text_focused { + 0 + } else { + i.count_and_consume_key(egui::Modifiers::NONE, egui::Key::Backspace) + i.count_and_consume_key(egui::Modifiers::NONE, egui::Key::Delete) + }; + undo + clear + }); + if n == 0 { + return; + } + let left = d.points.len().saturating_sub(n); + d.points.truncate(left); + if left == 0 { + app.ui.mask_pen = None; + } +} + fn pen_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, frame: (u32, u32)) { let Some(draft) = app.ui.mask_pen.clone() else { return }; let ph = app.session.playhead(); diff --git a/crates/ui-egui/tests/masks_ui.rs b/crates/ui-egui/tests/masks_ui.rs index 813d9eac..5ba753c8 100644 --- a/crates/ui-egui/tests/masks_ui.rs +++ b/crates/ui-egui/tests/masks_ui.rs @@ -258,6 +258,76 @@ fn shift_constrains_pen_mask_segments_to_45_degrees() { assert!(ox > 10.0 && oy.abs() < 1e-6, "handle constrained horizontal: {pen}"); } +/// Delete / Backspace and Cmd+Z take back the last pen point while a mask is drawn: the draft is +/// outside the undo history, so the keys must not reach Clear (on the clip being masked) or Undo +/// (of the previous project edit). The last point taken back closes the draft (#1345). +#[test] +fn delete_and_undo_take_back_the_last_pen_point() { + 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]})); + // a project edit for a stray Undo to revert + d.exec("effects.apply", json!({"effect": "gaussian_blur"})); + d.frames(3); + let undo_len = |d: &mut Driver| d.exec("history.list", json!({}))["undo"].as_array().unwrap().len(); + let clip_there = |d: &mut Driver| { + let seq = d.exec("sequence.inspect", json!({})); + seq["video"][0]["items"].as_array().unwrap().iter().any(|c| c["clip"].as_u64() == Some(id)) + }; + let undo0 = undo_len(&mut d); + 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}); + for (fx, fy) in [(0.3, 0.3), (0.6, 0.3), (0.6, 0.6)] { + d.ok("ui.click", at(fx, fy)); + d.frames(2); + } + // with the Timeline focused, Delete and Backspace are Clear on the selected clip + d.ok("ui.set", json!({"focused": "Timeline"})); + // Redo is not a take-back + d.ok("ui.key", json!({"key": "Shift+Cmd+Z"})); + d.frames(2); + d.ok("ui.key", json!({"key": "Delete"})); + d.frames(2); + assert!(clip_there(&mut d), "Delete cleared the clip being masked"); + d.ok("ui.key", json!({"key": "Cmd+Z"})); + d.frames(2); + assert_eq!(undo_len(&mut d), undo0, "Cmd+Z undid a project edit while drawing"); + // one point left: two more, then a click on the first closes the mask with three vertices + for (fx, fy) in [(0.35, 0.65), (0.25, 0.5)] { + d.ok("ui.click", at(fx, fy)); + d.frames(2); + } + 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(), 3, "{pen}"); + let (p0, p1) = (v0(pen, 0), v0(pen, 1)); + assert!(p1.1 > p0.1 + 50.0, "second vertex is the one placed after the take-backs: {p0:?} {p1:?}"); + + // Backspace on the only point closes the draft; Cmd+Z then undoes as usual + d.ok("ui.click", json!({"id": "effectControls.opacity.mask.pen"})); + d.frames(2); + d.ok("ui.click", at(0.5, 0.5)); + d.frames(2); + d.ok("ui.set", json!({"focused": "Timeline"})); + d.ok("ui.key", json!({"key": "Backspace"})); + d.frames(2); + assert!(clip_there(&mut d), "Backspace cleared the clip being masked"); + assert!(d.ok("ui.elements", json!({"prefix": "program.maskPen"})).as_array().unwrap().is_empty(), "draft still open"); + let undo1 = undo_len(&mut d); + d.ok("ui.key", json!({"key": "Cmd+Z"})); + d.frames(2); + assert_eq!(undo_len(&mut d), undo1 - 1); + assert!(d.masks().iter().all(|x| x["effectId"] != "opacity"), "Undo removed the pen mask"); +} + #[test] fn presets_bin_drag_and_save_dialog() { let mut d = Driver::demo(); From 1d62db01725c8a2acb97906534bca4b33e19208b Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 19:35:54 +0200 Subject: [PATCH 30/73] #1368: nudge clip selection up/down no longer fails for linked video+audio Problem: timeline.nudgeUp/nudgeDown returned an error for the whole selection when any selected clip was already on the outermost track in that direction. A video clip with its linked audio (A1 has no track above) therefore could never be nudged. Fix: clips with no destination track stay where they are; the command only errors when nothing could move. Regression test covers a linked selection. Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/keyboard.rs | 11 +++++++++-- crates/engine/src/keyboard_tests.rs | 8 ++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/keyboard.rs b/crates/engine/src/keyboard.rs index 55ffcb7d..5d344943 100644 --- a/crates/engine/src/keyboard.rs +++ b/crates/engine/src/keyboard.rs @@ -225,6 +225,7 @@ fn nudge_time(s: &mut Session, frames: i64) -> Result { fn nudge_track(s: &mut Session, up: bool) -> Result { let seq = s.active_sequence().ok_or(EngineError::NoSequence)?; let mut moves = Vec::new(); + let mut blocked = 0usize; for c in &s.state.selection { let Some((tid, it)) = seq.find_item(*c) else { continue }; let Some(tr) = seq.track(tid) else { continue }; @@ -234,11 +235,17 @@ fn nudge_track(s: &mut Session, up: bool) -> Result { (TrackKind::Video, true) | (TrackKind::Audio, false) => 1, _ => -1, }; - let dest = usize::try_from(idx + step).ok().and_then(|i| list.get(i)).ok_or_else(|| EngineError::Other("no track to move to".into()))?; + // a clip already on the outermost track stays put (a linked video+audio selection must not + // fail because its audio has nowhere to go) + let Some(dest) = usize::try_from(idx + step).ok().and_then(|i| list.get(i)) else { + blocked += 1; + continue; + }; moves.push((*c, dest.id, it.start)); } if moves.is_empty() { - return Err(EngineError::Other("no clips selected".into())); + let why = if blocked > 0 { "no track to move to" } else { "no clips selected" }; + return Err(EngineError::Other(why.into())); } s.edit_sequence("Nudge", |q, ctx, _| Ok(edit::move_items(q, &moves, false, ctx)?))?; Ok(json!({"moved": moves.len()})) diff --git a/crates/engine/src/keyboard_tests.rs b/crates/engine/src/keyboard_tests.rs index 60e9b7b4..4e6ff2fd 100644 --- a/crates/engine/src/keyboard_tests.rs +++ b/crates/engine/src/keyboard_tests.rs @@ -190,6 +190,14 @@ fn nudge_slip_and_slide_the_selection_with_undo() { s.execute("timeline.nudgeDown", json!({})).unwrap(); assert_eq!(track_of(&s, last.0), v1_id); assert!(s.execute("timeline.nudgeDown", json!({})).is_err(), "there is no track below V1"); + // a linked video+audio selection: the video goes up, the audio (already on the outermost + // track in that direction) stays, instead of the whole nudge failing (#1368) + s.execute("timeline.select", json!({"clips": [last.0]})).unwrap(); + assert!(s.state.selection.len() > 1, "the linked audio is selected too"); + s.execute("timeline.nudgeUp", json!({})).unwrap(); + assert_eq!(track_of(&s, last.0), s.active_sequence().unwrap().video_tracks[1].id); + s.execute("timeline.nudgeDown", json!({})).unwrap(); + assert_eq!(track_of(&s, last.0), v1_id); // slip: the source In moves, the clip stays let mid = v1[1]; s.execute("timeline.select", json!({"clips": [mid.0]})).unwrap(); From 5553e26e120c46fd2db1673c36e154646df7ce53 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 19:39:44 +0200 Subject: [PATCH 31/73] #1371: export preset query also matches the format id Problem: `export --list-presets h264` returned nothing because the query only matched name, description and category, while `h264` is the format id the CLI takes everywhere else. Fix: also match the preset's format id and label, compared with punctuation stripped, so `h264`, `h.264` and `H.264` are equivalent. Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/export_tests.rs | 7 +++++++ crates/engine/src/export_tools.rs | 10 ++++++++++ 2 files changed, 17 insertions(+) diff --git a/crates/engine/src/export_tests.rs b/crates/engine/src/export_tests.rs index d5f1883e..b4690748 100644 --- a/crates/engine/src/export_tests.rs +++ b/crates/engine/src/export_tests.rs @@ -360,6 +360,13 @@ fn user_presets_persist_with_favourites_import_and_export() { assert_eq!(favs, 2); let hits = s.execute("export.presets.list", json!({"query": "prores"})).unwrap()["presets"].as_array().unwrap().len(); assert_eq!(hits, 8, "seven QuickTime ProRes presets and MXF OP1a ProRes 422 HQ"); + // #1371: the query also matches the format id and the normalised label + let h264_only = s.execute("export.presets.list", json!({"format": "h264"})).unwrap()["presets"].as_array().unwrap().len(); + let mut count = |q: &str| s.execute("export.presets.list", json!({"query": q})).unwrap()["presets"].as_array().unwrap().len(); + assert!(count("h264") > 0, "format id `h264` finds the H.264 presets"); + assert!(count("h264") >= h264_only, "every h264 preset is found by its format id"); + assert_eq!(count("h.264"), count("H.264")); + assert!(count("h.264") >= h264_only); // 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/engine/src/export_tools.rs b/crates/engine/src/export_tools.rs index 1d316aa5..6f4c8b26 100644 --- a/crates/engine/src/export_tools.rs +++ b/crates/engine/src/export_tools.rs @@ -953,8 +953,14 @@ fn preset_json(s: &Session, pr: &ExportPreset, full: bool) -> Value { v } +/// Lower-cased alphanumerics only, so `h264`, `h.264` and `H.264` compare equal. +fn squash_alnum(s: &str) -> String { + s.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect() +} + fn presets_list(s: &mut Session, p: &Value) -> Result { let query = str_p(p, "query").unwrap_or("").to_ascii_lowercase(); + let squashed = squash_alnum(&query); let cat = str_p(p, "category").map(str::to_ascii_lowercase); let fav = bool_p(p, "favorites").unwrap_or(false); let fmt = str_p(p, "format").and_then(Format::from_name); @@ -967,6 +973,10 @@ fn presets_list(s: &mut Session, p: &Value) -> Result { || x.name.to_ascii_lowercase().contains(&query) || x.description.to_ascii_lowercase().contains(&query) || x.category.to_ascii_lowercase().contains(&query) + // The format id (`h264`) and label (`H.264`) are equivalent spellings. + || (!squashed.is_empty() + && (squash_alnum(x.settings.format.id()).contains(&squashed) + || squash_alnum(x.settings.format.label()).contains(&squashed))) }) .filter(|x| cat.as_ref().is_none_or(|c| x.category.to_ascii_lowercase() == *c)) .filter(|x| !fav || s.export_presets.is_favorite(&x.name)) From 6192214defbe3e11d3b4483c2262d5e670f4cc60 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 19:45:00 +0200 Subject: [PATCH 32/73] fix(previews): a nest's audio preview follows the sound of its sequence (#1370) The content hash of a nested sequence covered only its video tracks, for audio segments too. Changing the sound inside a nest (a clip's gain, a muted track, a new clip) left the outer sequence's rendered audio preview valid, so playback kept the old mix, or silence. Audio segments now hash the nested sequence's mix (audio tracks, their clips recursively, master and submixes) instead of its picture, so editing the picture inside a nest no longer invalidates its sound either. --- crates/render/src/preview.rs | 38 +++++++++++++++------ crates/render/src/preview_tests.rs | 53 +++++++++++++++++++++++++++++- 2 files changed, 80 insertions(+), 11 deletions(-) diff --git a/crates/render/src/preview.rs b/crates/render/src/preview.rs index 8079502b..7f5c2430 100644 --- a/crates/render/src/preview.rs +++ b/crates/render/src/preview.rs @@ -39,7 +39,7 @@ //! size and rate match the sequence. Green (a valid preview exists) is decided by the preview //! store, which knows which hashes have files. -use filmcraft_project::{ClipId, ItemId, ItemKind, Project, Sequence, TrackItem, Transition}; +use filmcraft_project::{ClipId, ItemId, ItemKind, Project, Sequence, TrackItem, TrackKind, Transition}; use filmcraft_time::{FrameRate, Tick}; use serde::Serialize; @@ -166,7 +166,7 @@ pub fn video_segments(project: &Project, seq_id: ItemId) -> Vec { match l { Layer::Item(ti, it) => { h.json(&("item", ti)); - hash_item(&mut h, project, it, t, 0); + hash_item(&mut h, project, it, t, TrackKind::Video, 0); absolute |= prints_time(it); clips.push(it.id); } @@ -180,7 +180,7 @@ pub fn video_segments(project: &Project, seq_id: ItemId) -> Vec { for side in [a, b] { match side { Some(it) => { - hash_item(&mut h, project, it, t, 0); + hash_item(&mut h, project, it, t, TrackKind::Video, 0); absolute |= prints_time(it); clips.push(it.id); } @@ -277,7 +277,7 @@ pub fn audio_segments(project: &Project, seq_id: ItemId) -> Vec { for side in [x.from, x.to] { match side.and_then(|id| tr.item(id)).filter(|i| i.enabled) { Some(it) => { - hash_item(&mut h, project, it, t, 0); + hash_item(&mut h, project, it, t, TrackKind::Audio, 0); heard = true; } None => h.json(&"none"), @@ -285,7 +285,7 @@ pub fn audio_segments(project: &Project, seq_id: ItemId) -> Vec { } } else if let Some(it) = tr.item_at(t).filter(|i| i.enabled) { h.json(&("item", ti)); - hash_item(&mut h, project, it, t, 0); + hash_item(&mut h, project, it, t, TrackKind::Audio, 0); heard = true; } } @@ -300,7 +300,9 @@ fn prints_time(it: &TrackItem) -> bool { it.effects.iter().any(|e| e.enabled && e.effect == "timecode") } -fn hash_item(h: &mut Fnv128, project: &Project, it: &TrackItem, origin: Tick, depth: u32) { +/// Hash a clip and its source as heard (`kind` Audio) or seen (`kind` Video): a nested sequence +/// contributes only the tracks of that kind, so editing its picture keeps its sound's previews. +fn hash_item(h: &mut Fnv128, project: &Project, it: &TrackItem, origin: Tick, kind: TrackKind, depth: u32) { let mut c = it.clone(); c.id = Default::default(); c.start -= origin; @@ -312,10 +314,10 @@ fn hash_item(h: &mut Fnv128, project: &Project, it: &TrackItem, origin: Tick, de c.name.clear(); } h.json(&c); - hash_source(h, project, it.item, depth); + hash_source(h, project, it.item, kind, depth); } -fn hash_source(h: &mut Fnv128, project: &Project, id: ItemId, depth: u32) { +fn hash_source(h: &mut Fnv128, project: &Project, id: ItemId, kind: TrackKind, depth: u32) { let Some(pi) = project.item(id) else { h.json(&"missing"); return; @@ -338,18 +340,34 @@ fn hash_source(h: &mut Fnv128, project: &Project, id: ItemId, depth: u32) { } ItemKind::Subclip { parent, range, .. } => { h.json(&("subclip", range)); - hash_source(h, project, *parent, depth + 1); + hash_source(h, project, *parent, kind, depth + 1); } ItemKind::AdjustmentLayer { .. } => h.json(&("adjustment", &pi.kind)), ItemKind::Graphic { .. } => h.json(&("graphic", &pi.kind)), ItemKind::Sequence(s) => { h.json(&("sequence", &s.settings)); + if kind == TrackKind::Audio { + // the nest's sound is the nested sequence's whole mix (see `audio_segments`) + h.json(&("mix", s.master_volume_db, &s.master_effects, &s.master_mixer, &s.submix_tracks, &s.multicam)); + for (ti, tr) in s.audio_tracks.iter().enumerate() { + h.json(&("track", ti, tr.muted, tr.solo, tr.volume_db, tr.pan, tr.channels, &tr.effects, &tr.mixer, tr.items.len())); + for it in &tr.items { + hash_item(h, project, it, Tick::ZERO, kind, depth + 1); + } + for x in &tr.transitions { + let mut xc = x.clone(); + xc.id = Default::default(); + h.json(&xc); + } + } + return; + } // a nested sequence's captions are drawn into its picture h.json(&("captions", &s.caption_tracks)); for (ti, tr) in s.video_tracks.iter().enumerate() { h.json(&("track", ti, tr.enabled, tr.items.len())); for it in &tr.items { - hash_item(h, project, it, Tick::ZERO, depth + 1); + hash_item(h, project, it, Tick::ZERO, kind, depth + 1); } for x in &tr.transitions { let mut xc = x.clone(); diff --git a/crates/render/src/preview_tests.rs b/crates/render/src/preview_tests.rs index d13d5b34..440bd10c 100644 --- a/crates/render/src/preview_tests.rs +++ b/crates/render/src/preview_tests.rs @@ -5,7 +5,7 @@ use filmcraft_media::{DemoScene, Generator, MediaSource}; use filmcraft_project::{ClipId, ItemId, ItemKind, Label, MediaClip, MediaRef, ParamValue, Project, SequenceSettings, TrackKind, find_effect}; use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick, TimeRange}; -use crate::preview::{Need, Segment, effect_cost_ms, segment_at, video_segments}; +use crate::preview::{Need, Segment, audio_segments, effect_cost_ms, segment_at, video_segments}; fn add_gen(p: &mut Project, g: GeneratorSource) -> ItemId { let info = g.info().clone(); @@ -248,3 +248,54 @@ fn effect_costs() { println!("{ms:8.2} ms {id:24} tier {:.0}", effect_cost_ms(id)); } } + +/// Put `item` on track 0 of `kind` in `seq` from frame `start_f` for `dur_f` frames. +fn place_kind(p: &mut Project, seq: ItemId, kind: TrackKind, item: ItemId, start_f: i64, dur_f: i64) -> ClipId { + let r = FrameRate::FPS_24; + let ti = p.make_track_item(item, kind, r.tick_of(start_f), TimeRange::new(Tick::ZERO, r.tick_of(dur_f)), r).unwrap(); + let id = ti.id; + let t = &mut p.sequence_mut(seq).unwrap().tracks_mut(kind)[0]; + t.items.push(ti); + t.sort(); + id +} + +fn audio_hashes(p: &Project, seq: ItemId) -> Vec { + audio_segments(p, seq).into_iter().map(|g| g.hash).collect() +} + +#[test] +fn a_nests_audio_previews_follow_the_sound_of_its_sequence() { + // Inner has a picture clip and a sound clip; Outer nests Inner (picture and sound), and Top + // nests Outer's sound + let (mut p, matte, ocean, inner) = setup(); + let pic = place_kind(&mut p, inner, TrackKind::Video, matte, 0, 48); + let snd = place_kind(&mut p, inner, TrackKind::Audio, ocean, 0, 48); + let settings = p.sequence(inner).unwrap().settings.clone(); + let outer = p.new_sequence("outer", settings.clone(), 1, 1, None); + place_kind(&mut p, outer, TrackKind::Video, inner, 24, 48); + place_kind(&mut p, outer, TrackKind::Audio, inner, 24, 48); + let top = p.new_sequence("top", settings, 1, 1, None); + place_kind(&mut p, top, TrackKind::Audio, outer, 12, 72); + let (audio, picture, deep) = (audio_hashes(&p, outer), video_segments(&p, outer), audio_hashes(&p, top)); + assert_eq!(audio.len(), 1); + + // the sound inside the nest changes: the nest's audio preview is stale, its picture is not + p.sequence_mut(inner).unwrap().find_item_mut(snd).unwrap().1.gain_db = -12.0; + assert_ne!(audio_hashes(&p, outer), audio, "a clip's gain inside the nest"); + assert_ne!(audio_hashes(&p, top), deep, "a clip's gain two nests down"); + assert_eq!(bounds(&video_segments(&p, outer)), bounds(&picture)); + assert_eq!(by_frame(&video_segments(&p, outer), 30), by_frame(&picture, 30), "the picture did not change"); + let (audio, deep) = (audio_hashes(&p, outer), audio_hashes(&p, top)); + // the nested sequence's track mix counts too + p.sequence_mut(inner).unwrap().audio_tracks[0].muted = true; + assert_ne!(audio_hashes(&p, outer), audio, "a muted track inside the nest"); + p.sequence_mut(inner).unwrap().audio_tracks[0].muted = false; + assert_eq!(audio_hashes(&p, outer), audio); + + // the picture inside the nest changes: its audio preview stays valid + add_fx(&mut p, inner, pic, "tint"); + assert_ne!(by_frame(&video_segments(&p, outer), 30), by_frame(&picture, 30)); + assert_eq!(audio_hashes(&p, outer), audio, "a video effect inside the nest"); + assert_eq!(audio_hashes(&p, top), deep); +} From 20d196401ac8f14e3bbf29c74689a41e69dba5f0 Mon Sep 17 00:00:00 2001 From: webdevtodayjason <115739170+webdevtodayjason@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:45:10 +0000 Subject: [PATCH 33/73] Captions: preserve tab-separated WebVTT voices --- crates/captions/src/vtt.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/captions/src/vtt.rs b/crates/captions/src/vtt.rs index 8c1d57bd..a1afb244 100644 --- a/crates/captions/src/vtt.rs +++ b/crates/captions/src/vtt.rs @@ -38,7 +38,7 @@ pub fn split_voice(text: &str) -> (Option, String) { Some(_) => return (None, text.to_string()), None => body.to_string(), }; - if body.contains("hi"), (None, "hi".into())); } + #[test] + fn multiple_voices_with_tab_separator() { + let text = "Hello there"; + assert_eq!(split_voice(text), (None, text.to_string())); + let source = format!("WEBVTT\n\n00:00:01.000 --> 00:00:02.000\n{text}\n"); + let doc = parse(&source).unwrap(); + assert_eq!(doc.cues[0].speaker, None); + assert_eq!(doc.cues[0].text, text); + assert_eq!(parse(&write(&doc)).unwrap().cues, doc.cues); + let closed = "Hi x"; + assert_eq!(split_voice(closed), (None, closed.to_string())); + } + #[test] fn without_signature() { let d = parse("00:00:01.000 --> 00:00:02.000\nx\n").unwrap(); From 3516207de7975e12c404a259cedc2a0bc931fe72 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 20:55:35 +0200 Subject: [PATCH 34/73] #1402: import MP1 (A_MPEG/L1) audio from Matroska Problem: the Matroska demuxer mapped A_MPEG/L2 and A_MPEG/L3 only, so a Layer I track was reported as an unsupported codec even though the MP1/MP2 decoder (#809) reads Layer I. Fix: add Codec::Mp1 for A_MPEG/L1 and route it through the same packet duration and decoder path as Mp2. Test remuxes synthetic Layer I frames into Matroska with ffmpeg and compares our decode (max error 3e-5). Co-Authored-By: Claude Sonnet 5.5 --- crates/codecs/src/mkv.rs | 5 +- crates/codecs/tests/mkv_mp1_oracle.rs | 89 +++++++++++++++++++++++++++ crates/matroska/README.md | 2 +- crates/matroska/src/codec.rs | 4 ++ docs/gaps.md | 5 +- 5 files changed, 100 insertions(+), 5 deletions(-) create mode 100644 crates/codecs/tests/mkv_mp1_oracle.rs diff --git a/crates/codecs/src/mkv.rs b/crates/codecs/src/mkv.rs index a91160a7..2f51258a 100644 --- a/crates/codecs/src/mkv.rs +++ b/crates/codecs/src/mkv.rs @@ -83,7 +83,7 @@ fn audio_starts(file: &MkvFile, bytes: &crate::Src, ti: usize, rate: i64) -> Vec let out_rate = u32::try_from(rate).unwrap_or(0); let frame = match &t.codec { Codec::Aac { asc } => fixed_packet_samples(FixedFrames::Aac(asc), &[], out_rate), - Codec::Mp3 | Codec::Mp2 => fixed_packet_samples(FixedFrames::MpegAudio, &first(), out_rate), + Codec::Mp3 | Codec::Mp2 | Codec::Mp1 => 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, @@ -317,7 +317,7 @@ fn codec_label(c: &Codec) -> String { Codec::Flac { .. } => "FLAC".into(), Codec::Pcm { bits, float, .. } => format!("PCM {bits}-bit{}", if *float { " float" } else { "" }), Codec::Mp3 => "MP3".into(), - Codec::Mp2 => "MPEG Audio".into(), + Codec::Mp2 | Codec::Mp1 => "MPEG Audio".into(), Codec::Other(id) if id == "V_MPEG2" => "MPEG-2 Video".into(), Codec::Other(id) if id == "V_MPEG1" => "MPEG-1 Video".into(), other => other.name().to_string(), @@ -486,6 +486,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::Mp1 => PacketDecoder::mpeg_audio(1, rate), 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)), diff --git a/crates/codecs/tests/mkv_mp1_oracle.rs b/crates/codecs/tests/mkv_mp1_oracle.rs new file mode 100644 index 00000000..05d9810f --- /dev/null +++ b/crates/codecs/tests/mkv_mp1_oracle.rs @@ -0,0 +1,89 @@ +//! MPEG audio Layer I (MP1) in Matroska (`A_MPEG/L1`, #1402) against ffmpeg's decode. No +//! installed encoder writes Layer I, so the frames are synthetic (valid random Layer I frames, +//! as in `crates/mpa/tests/oracle.rs`) and ffmpeg only remuxes them into Matroska and decodes. + +mod common; + +use common::*; + +struct Bits(Vec, usize); +impl Bits { + fn put(&mut self, v: u32, n: u32) { + for i in (0..n).rev() { + if self.1.is_multiple_of(8) { + self.0.push(0); + } + if (v >> i) & 1 == 1 { + let last = self.0.len() - 1; + self.0[last] |= 0x80 >> (self.1 % 8); + } + self.1 += 1; + } + } +} + +/// Mono Layer I frames, MPEG-1, 48 kHz, 192 kbit/s (192-byte frames): allocations on the first 8 +/// subbands only, so every frame fits. +fn layer1_stream(frames: usize) -> Vec { + let mut seed = 0x2545_F491u32; + let mut rnd = |n: u32| { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + seed % n + }; + let mut out = Vec::new(); + for _ in 0..frames { + let mut b = Bits(Vec::new(), 0); + for byte in [0xFFu32, 0xFF, 6 << 4 | 1 << 2, 3 << 6] { + b.put(byte, 8); + } + let alloc: Vec = (0..32).map(|sb| if sb < 8 { 1 + rnd(8) } else { 0 }).collect(); + for &a in &alloc { + b.put(a, 4); + } + for &a in &alloc { + if a != 0 { + b.put(10 + rnd(30), 6); + } + } + for _ in 0..12 { + for &a in &alloc { + if a != 0 { + b.put(rnd((1 << (a + 1)) - 1), a + 1); + } + } + } + let mut f = b.0; + f.resize(192, 0); + out.extend(f); + } + out +} + +#[test] +fn mp1_in_matroska_matches_ffmpeg() { + let ff = filmcraft_testkit::require_ffmpeg!(); + let raw = dir().join("synthetic_layer1.mp1"); + std::fs::create_dir_all(dir()).unwrap(); + std::fs::write(&raw, layer1_stream(250)).unwrap(); + let Some(mkv) = fixture(&ff, "synthetic_layer1.mkv", &["-f", "mp3", "-i", raw.to_str().unwrap(), "-c:a", "copy"]) else { + eprintln!("SKIPPED: this ffmpeg can't remux Layer I into Matroska"); + return; + }; + let m = filmcraft_matroska::open(bytes(&mkv)).unwrap(); + assert!(matches!(m.tracks.first().map(|t| &t.codec), Some(filmcraft_matroska::Codec::Mp1)), "A_MPEG/L1 maps to Codec::Mp1"); + let src = filmcraft_codecs::open_bytes("synthetic_layer1.mkv", bytes(&mkv)).unwrap(); + let a = src.info().audio().cloned().unwrap(); + assert_eq!((a.sample_rate, a.channels), (48_000, 1)); + let want = ffmpeg_audio_f32(&ff, &mkv, &[]); + let n = want.len().min(250 * 384) - 800; + let got = src.audio(0, n, 48_000).unwrap(); + let peak = want.iter().fold(0f32, |m, x| m.max(x.abs())); + // ffmpeg's CLI may or may not drop the decoder delay of a stream copy; search a few lags + let (worst, lag) = (-2i64..=2) + .map(|lag| ((2..n - 2).map(|i| (want[(i as i64 + lag) as usize] - got.channels[0][i]).abs()).fold(0f32, f32::max), lag)) + .fold((f32::INFINITY, 0), |m, x| if x.0 < m.0 { x } else { m }); + println!("mkv Layer I: max error {worst} at lag {lag}, peak {peak}"); + assert!(peak > 0.01 && worst <= 2e-4, "peak {peak}, max error {worst} at lag {lag}"); +} diff --git a/crates/matroska/README.md b/crates/matroska/README.md index a4805b6f..40210ad0 100644 --- a/crates/matroska/README.md +++ b/crates/matroska/README.md @@ -73,7 +73,7 @@ while let Some(p) = d.next_packet()? { // file order, all tracks | `A_VORBIS` | `Vorbis` | three headers split from Xiph-laced CodecPrivate | | `A_FLAC` | `Flac` | `fLaC` + metadata blocks | | `A_PCM/INT/LIT`, `A_PCM/INT/BIG`, `A_PCM/FLOAT/IEEE` | `Pcm` | bit depth from Audio | -| `A_AC3`, `A_EAC3`, `A_MPEG/L3`, `A_MPEG/L2` | `Ac3`, `Eac3`, `Mp3`, `Mp2` | | +| `A_AC3`, `A_EAC3`, `A_MPEG/L3`, `A_MPEG/L2`, `A_MPEG/L1` | `Ac3`, `Eac3`, `Mp3`, `Mp2`, `Mp1` | | | `S_TEXT/UTF8` | `SubRip` | | | `S_TEXT/WEBVTT`, `D_WEBVTT/*` (WebM) | `WebVtt` | WebM blocks keep `id\nsettings\npayload` | | `S_TEXT/ASS`, `S_TEXT/SSA` | `Ass` | script header | diff --git a/crates/matroska/src/codec.rs b/crates/matroska/src/codec.rs index 21de3715..8d83a974 100644 --- a/crates/matroska/src/codec.rs +++ b/crates/matroska/src/codec.rs @@ -45,6 +45,8 @@ pub enum Codec { Mp3, /// `A_MPEG/L2`. Mp2, + /// `A_MPEG/L1`. + Mp1, /// `S_TEXT/UTF8` (SubRip-style plain text). SubRip, /// `S_TEXT/WEBVTT`, or WebM's `D_WEBVTT/{SUBTITLES,CAPTIONS,DESCRIPTIONS,METADATA}`. @@ -86,6 +88,7 @@ impl Codec { Codec::Eac3 => "eac3".into(), Codec::Mp3 => "mp3".into(), Codec::Mp2 => "mp2".into(), + Codec::Mp1 => "mp1".into(), Codec::SubRip => "subrip".into(), Codec::WebVtt => "webvtt".into(), Codec::Ass { ssa, .. } => if *ssa { "ssa" } else { "ass" }.into(), @@ -173,6 +176,7 @@ pub(crate) fn map_codec(id: &str, private: &[u8], audio: Option<&crate::AudioInf "A_EAC3" => Codec::Eac3, "A_MPEG/L3" => Codec::Mp3, "A_MPEG/L2" => Codec::Mp2, + "A_MPEG/L1" => Codec::Mp1, "S_TEXT/UTF8" => Codec::SubRip, "S_TEXT/WEBVTT" | "D_WEBVTT/SUBTITLES" | "D_WEBVTT/CAPTIONS" | "D_WEBVTT/DESCRIPTIONS" | "D_WEBVTT/METADATA" => Codec::WebVtt, "S_TEXT/ASS" | "S_ASS" => Codec::Ass { header: p, ssa: false }, diff --git a/docs/gaps.md b/docs/gaps.md index 9b2a2796..628b099e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -178,12 +178,12 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** - **Have:** E-AC-3 (Dolby Digital Plus) in MP4, MOV, Matroska and MPEG-TS (#647): independent substream, 1–6 blocks per syncframe, spectral extension. MP1 / MP2 (MPEG audio Layer I / II): - own decoder (#809) in raw files, MP4 and MOV (#800), MPEG-TS / PS and Matroska, including the + own decoder (#809) in raw files, MP4 and MOV (#800), MPEG-TS / PS and Matroska (`A_MPEG/L1` and `L2`, #1402), including the bit rate / mode combinations ISO/IEC 11172-3 disallows but ffmpeg writes (its mono default is 384 kbit/s). OpenEXR stills and image sequences (#828): scan-line, uncompressed / RLE / ZIPS / ZIP, half / float / uint, RGB(A) or luminance, decoded to linear float frames and bit-exact against ffmpeg. -- **Missing:** free-format MPEG audio, MP1 in Matroska (`A_MPEG/L1`), E-AC-3 adaptive hybrid +- **Missing:** free-format MPEG audio, 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 @@ -306,3 +306,4 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** | 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 | +| 2026-10-12 | minor | G10: MP1 (`A_MPEG/L1`) in Matroska imports (#1402) | From 9deba4a830592f33452e429167197a28dd548069 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 21:12:19 +0200 Subject: [PATCH 35/73] #1408: Shift constrains mask vertex and whole-mask drags to 45 degrees Problem: Shift snapped pen segments (#853) but dragging an existing mask vertex or the whole mask still moved freely. Fix: while Shift is held the movement since the press is constrained to horizontal/vertical/diagonal steps (accumulated raw vs applied totals per gesture, so each frame's delta stays a single undo step). Unit test for the pure step function; gaps.md G18 updated. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/panels/masks.rs | 51 ++++++++++++++++++++++++++++-- docs/gaps.md | 5 +-- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels/masks.rs b/crates/ui-egui/src/panels/masks.rs index c741a1d3..c980b6ac 100644 --- a/crates/ui-egui/src/panels/masks.rs +++ b/crates/ui-egui/src/panels/masks.rs @@ -302,7 +302,10 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra ui.data_mut(|d| d.insert_temp(gesture_id.with("body"), ok)); } if body.dragged_by(LEFT) && ui.data(|d| d.get_temp::(gesture_id.with("body"))).unwrap_or(false) && body.drag_delta() != egui::Vec2::ZERO { - actions.push(("masks.translate".into(), with(json!({"delta": clip_delta(body.drag_delta()), "merge": format!("body-{gesture}")})))); + let d = drag_step(ui, egui::Id::new(("mask-body-drag", gesture)), body.drag_delta()); + if d != egui::Vec2::ZERO { + actions.push(("masks.translate".into(), with(json!({"delta": clip_delta(d), "merge": format!("body-{gesture}")})))); + } } if body.clicked() && !ptr_in(body.interact_pointer_pos()) { actions.push(("masks.select".into(), json!({"none": true}))); @@ -392,7 +395,10 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra ui.data_mut(|d| d.insert_temp(sel_v_id, i)); } if r.dragged_by(LEFT) && r.drag_delta() != egui::Vec2::ZERO { - actions.push(("masks.moveVertex".into(), with(json!({"vertex": i, "delta": clip_delta(r.drag_delta()), "merge": format!("vertex-{gesture}")})))); + let d = drag_step(ui, egui::Id::new(("mask-vertex-drag", gesture, i)), r.drag_delta()); + if d != egui::Vec2::ZERO { + actions.push(("masks.moveVertex".into(), with(json!({"vertex": i, "delta": clip_delta(d), "merge": format!("vertex-{gesture}")})))); + } } if r.clicked() { if alt { @@ -678,6 +684,27 @@ fn open_polyline(path: &MaskPath, to_screen: &Affine) -> Vec { out } +/// This frame's drag delta for a whole-mask or vertex drag. With Shift held the movement since the +/// press is constrained to 45° steps (horizontal, vertical, diagonal); the state (raw and applied +/// totals) lives under `id`, which includes the pointer-press counter so each drag starts fresh. +fn drag_step(ui: &egui::Ui, id: egui::Id, delta: egui::Vec2) -> egui::Vec2 { + let shift = ui.input(|i| i.modifiers.shift); + let (raw, applied) = ui.data(|d| d.get_temp::<(egui::Vec2, egui::Vec2)>(id)).unwrap_or_default(); + let (raw, applied, step) = shift_drag(raw, applied, delta, shift); + ui.data_mut(|d| d.insert_temp(id, (raw, applied))); + step +} + +/// Pure core of [`drag_step`]: `(raw total, applied total, step to apply now)`. +fn shift_drag(raw: egui::Vec2, applied: egui::Vec2, delta: egui::Vec2, shift: bool) -> (egui::Vec2, egui::Vec2, egui::Vec2) { + if !(delta.x.is_finite() && delta.y.is_finite()) { + return (raw, applied, egui::Vec2::ZERO); + } + let raw = raw + delta; + let target = if shift { constrain_45(Pos2::ZERO, raw.to_pos2()).to_vec2() } else { applied + delta }; + (raw, target, target - applied) +} + /// `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 { @@ -754,6 +781,26 @@ pub fn path_value(app: &mut FilmcraftApp, ui: &mut egui::Ui, clip: ClipId, effec mod tests { use super::*; + #[test] + fn shift_drag_constrains_total_movement_to_45_degree_steps() { + use egui::vec2; + let (mut raw, mut applied) = (egui::Vec2::ZERO, egui::Vec2::ZERO); + let mut sum = egui::Vec2::ZERO; + // a mostly horizontal drag stays on the x axis + for d in [vec2(10.0, 2.0), vec2(10.0, 3.0), vec2(10.0, 1.0)] { + let (r, a, s) = shift_drag(raw, applied, d, true); + (raw, applied) = (r, a); + sum += s; + } + assert!(sum.y.abs() < 1e-4 && (sum.x - 30.0).abs() < 1.0, "{sum:?}"); + // without shift the delta passes through unchanged + let (_, _, s) = shift_drag(egui::Vec2::ZERO, egui::Vec2::ZERO, vec2(3.0, 4.0), false); + assert_eq!(s, vec2(3.0, 4.0)); + // a non-finite delta is ignored + let (_, _, s) = shift_drag(egui::Vec2::ZERO, egui::Vec2::ZERO, vec2(f32::NAN, 1.0), true); + assert_eq!(s, egui::Vec2::ZERO); + } + #[test] fn constrain_45_snaps_to_the_nearest_eighth_turn() { let o = pos2(10.0, 20.0); diff --git a/docs/gaps.md b/docs/gaps.md index 9b2a2796..38b1ead0 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -278,7 +278,7 @@ 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 (Shift-constrained vertex and whole-mask drags; a stored, re-editable + redesigned 26.0 mask tools (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 @@ -286,7 +286,8 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** 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), from an on-monitor corner-roundness handle with a live preview (one undo step per drag; - #844); Shift snaps pen mask segments and dragged-out Bézier handles to 45° steps (#853). + #844); Shift snaps pen mask segments and dragged-out Bézier handles to 45° steps (#853), and + Shift constrains vertex and whole-mask drags to 45° steps from the press point (#1408). - **Estimate:** 60–110 h. ## G19. Speech to text off in release builds From 595f94f30321a8400a13939ae058cfc66ba7218e Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 21:12:44 +0200 Subject: [PATCH 36/73] #1407: import Radiance HDR stills and image sequences Problem: Premiere reads Radiance HDR (.hdr) pictures; FilmCraft listed the extension nowhere and could not open them. Fix: clean-room RGBE decoder in filmcraft-media from the public format description: header with FORMAT, all eight axis orders, flat, old run-length and new per-channel run-length scan lines. Frames are linear float (Rec.709), like EXR. Allocations are bounded by the data present; hostile headers and truncated data return errors. "hdr" joins the importable still extensions; stills and image sequences share the decoder. Output equals ffmpeg's decode exactly for flat and RLE files; EXPOSURE and PRIMARIES lines are ignored and XYZE is refused. Co-Authored-By: Claude Sonnet 5.5 --- crates/media/src/lib.rs | 5 +- crates/media/src/sequence.rs | 6 +- crates/media/src/still.rs | 7 +- crates/media/src/still/hdr.rs | 223 ++++++++++++++++++++++++++++ crates/media/src/still/hdr/tests.rs | 134 +++++++++++++++++ crates/media/tests/hdr_oracle.rs | 99 ++++++++++++ docs/file-format-parity.md | 3 +- docs/gaps.md | 5 +- 8 files changed, 476 insertions(+), 6 deletions(-) create mode 100644 crates/media/src/still/hdr.rs create mode 100644 crates/media/src/still/hdr/tests.rs create mode 100644 crates/media/tests/hdr_oracle.rs diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index 7742611e..f875705e 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -172,7 +172,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", "tga", "dpx", "exr", "dng"]; +pub const STILL_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "tga", "dpx", "exr", "hdr", "dng"]; 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", @@ -197,6 +197,9 @@ pub fn open_bytes(name: &str, bytes: Arc<[u8]>, extra: &[Opener]) -> Result Result<(VideoFrame, bool, String, Option)> { @@ -229,6 +229,10 @@ fn decode_frame(name: &str, bytes: &[u8]) -> Result<(VideoFrame, bool, String, O let d = crate::still::exr::decode(name, bytes)?; return Ok((d.frame, d.has_alpha, d.pixel_format, None)); } + if crate::still::hdr::sniff(bytes) { + let frame = crate::still::hdr::decode(name, bytes)?; + return Ok((frame, false, "RGB 32-bit float (RGBE)".to_string(), None)); + } if crate::still::dng::sniff(bytes) { let d = crate::still::dng::decode(name, bytes)?; return Ok((d.frame, false, d.pixel_format, Some(d.info))); diff --git a/crates/media/src/still.rs b/crates/media/src/still.rs index f2825d08..c208c4f5 100644 --- a/crates/media/src/still.rs +++ b/crates/media/src/still.rs @@ -1,4 +1,4 @@ -//! Still images (PNG, JPEG, GIF, WebP, TIFF, BMP, Targa, DPX, OpenEXR, DNG camera raw) as media +//! Still images (PNG, JPEG, GIF, WebP, TIFF, BMP, Targa, DPX, OpenEXR, Radiance HDR, DNG camera raw) as media //! sources. Animated PNG and GIF files open as movies instead ([`crate::animated`]); here they would //! show their first frame. @@ -11,6 +11,7 @@ use crate::{FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource, Result, pub mod dng; pub mod exr; +pub mod hdr; /// Default still duration (Premiere's preference default is 5 seconds). pub const DEFAULT_STILL_DURATION: Tick = Tick(5 * TICKS_PER_SECOND); @@ -18,6 +19,7 @@ pub const DEFAULT_STILL_DURATION: Tick = Tick(5 * TICKS_PER_SECOND); /// 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) + || hdr::sniff(b) || crate::tga::sniff(name, b) || b.starts_with(b"\x89PNG") || b.starts_with(&[0xFF, 0xD8, 0xFF]) @@ -64,6 +66,9 @@ impl StillSource { if dng::sniff(bytes) { return dng::still(name, bytes); } + if hdr::sniff(bytes) { + return hdr::still(name, bytes); + } let r = decode_raster(name, bytes)?; Ok(Self::from_rgba(name, r.width, r.height, r.rgba, r.has_alpha, bytes.len() as u64)) } diff --git a/crates/media/src/still/hdr.rs b/crates/media/src/still/hdr.rs new file mode 100644 index 00000000..4e0765bc --- /dev/null +++ b/crates/media/src/still/hdr.rs @@ -0,0 +1,223 @@ +//! Radiance HDR (`.hdr`, `.pic`) stills, read from Greg Ward's public RGBE format description +//! ("Real Pixels", Graphics Gems II): a text header (`#?RADIANCE` or `#?RGBE`, `FORMAT=32-bit_rle_rgbe`), +//! a blank line, a resolution line (`-Y h +X w` and the seven other axis orders), then one RGBE +//! pixel (shared exponent) per sample, flat, in the old run-length form or in the "new" per-channel +//! run-length form (scan lines of 8 to 32767 pixels). RGBE is scene-linear light, so the frame keeps +//! 32-bit floats (values above 1.0 survive into effects and colour work). The `EXPOSURE` and +//! `PRIMARIES` header lines are not applied, which is what other readers do too; the XYZE colour +//! format is refused. + +use std::sync::Arc; + +use filmcraft_color::{ColorInfo, Transfer}; +use filmcraft_frame::VideoFrame; +use filmcraft_time::FrameRate; + +use super::{DEFAULT_STILL_DURATION, StillSource}; +use crate::{MediaError, MediaInfo, MediaKind, Result, VideoStreamInfo}; + +/// Largest picture we decode (each pixel takes 16 bytes as RGBA floats). +const MAX_PIXELS: u64 = 1 << 26; +/// Longest header we read (real ones are a few hundred bytes). +const MAX_HEADER: usize = 64 << 10; + +/// The colour of HDR frames: scene-linear light, Rec.709 primaries. +const LINEAR_709: ColorInfo = ColorInfo { transfer: Transfer::Linear, ..ColorInfo::SRGB_FULL }; + +/// Whether `b` starts like a Radiance picture. +pub fn sniff(b: &[u8]) -> bool { + b.starts_with(b"#?RADIANCE") || b.starts_with(b"#?RGBE") +} + +fn err(name: &str, what: &str) -> MediaError { + MediaError::Decode(format!("{name}: Radiance HDR: {what}")) +} + +/// Open a Radiance picture as a still. +pub fn still(name: &str, bytes: &[u8]) -> Result { + let frame = decode(name, bytes)?; + let info = MediaInfo { + name: name.to_string(), + kind: MediaKind::Still, + duration: DEFAULT_STILL_DURATION, + video: Some(VideoStreamInfo { + width: frame.width, + height: frame.height, + frame_rate: FrameRate::FPS_29_97, + par: (1, 1), + codec: "HDR".into(), + pixel_format: "RGB 32-bit float (RGBE)".into(), + color: LINEAR_709, + has_alpha: false, + bitrate: None, + hdr: None, + }), + audio_streams: Vec::new(), + container: "Image".into(), + start_timecode: None, + file_size: Some(bytes.len() as u64), + }; + Ok(StillSource { info, frame: Arc::new(frame) }) +} + +/// One resolution-line axis: `(is_x, positive)`. +fn axis(tok: &str) -> Option<(bool, bool)> { + match tok { + "+X" => Some((true, true)), + "-X" => Some((true, false)), + "+Y" => Some((false, true)), + "-Y" => Some((false, false)), + _ => None, + } +} + +/// RGBE to linear floats: `mantissa * 2^(e - 136)`, zero for exponent 0. (Radiance's own reader adds +/// half a mantissa step; ffmpeg and stb_image do not, which also makes 1.0 = (128, 128, 128, 129) +/// read back as exactly 1.0.) +fn rgbe(p: [u8; 4]) -> [f32; 3] { + if p[3] == 0 { + return [0.0; 3]; + } + let scale = 2f32.powi(i32::from(p[3]) - 136); + [f32::from(p[0]) * scale, f32::from(p[1]) * scale, f32::from(p[2]) * scale] +} + +/// Read the pixels of one scan line of `n` pixels into `line`. +fn scanline(name: &str, b: &[u8], pos: &mut usize, line: &mut [[u8; 4]]) -> Result<()> { + let n = line.len(); + let take = |pos: &mut usize, k: usize| -> Result<&[u8]> { + let s = b.get(*pos..pos.saturating_add(k)).ok_or_else(|| err(name, "truncated pixel data"))?; + *pos += k; + Ok(s) + }; + let head = b.get(*pos..*pos + 4); + if (8..=32767).contains(&n) + && let Some(&[2, 2, hi, lo]) = head + && hi & 0x80 == 0 + { + if usize::from(hi) << 8 | usize::from(lo) != n { + return Err(err(name, "scan line length does not match the picture")); + } + *pos += 4; + for c in 0..4 { + let mut x = 0; + while x < n { + let code = take(pos, 1)?[0]; + if code > 128 { + let run = usize::from(code - 128); + let v = take(pos, 1)?[0]; + let dst = line.get_mut(x..x + run).ok_or_else(|| err(name, "run past the end of the scan line"))?; + for p in dst { + p[c] = v; + } + x += run; + } else { + let run = usize::from(code); + if run == 0 { + return Err(err(name, "empty literal run")); + } + let src = take(pos, run)?; + let dst = line.get_mut(x..x + run).ok_or_else(|| err(name, "literal past the end of the scan line"))?; + for (p, &v) in dst.iter_mut().zip(src) { + p[c] = v; + } + x += run; + } + } + } + return Ok(()); + } + // flat pixels, with the old run-length form: (1, 1, 1, k) repeats the previous pixel k << shift times + let (mut x, mut shift) = (0usize, 0u32); + while x < n { + let s = take(pos, 4)?; + let p = [s[0], s[1], s[2], s[3]]; + if p[..3] == [1, 1, 1] { + let count = usize::from(p[3]).checked_shl(shift).unwrap_or(usize::MAX); + let prev = if x > 0 { line.get(x - 1).copied() } else { None }.ok_or_else(|| err(name, "run with no previous pixel"))?; + let dst = line.get_mut(x..x.saturating_add(count).min(n)).ok_or_else(|| err(name, "run past the end of the scan line"))?; + dst.fill(prev); + x += dst.len(); + shift = shift.saturating_add(8).min(24); + } else { + if let Some(d) = line.get_mut(x) { + *d = p; + } + x += 1; + shift = 0; + } + } + Ok(()) +} + +/// Decode a Radiance picture to an opaque linear float frame. +pub fn decode(name: &str, b: &[u8]) -> Result { + if !sniff(b) { + return Err(err(name, "not a Radiance picture")); + } + // header: lines up to the first empty one, then the resolution line + let mut pos = 0; + let line = |pos: &mut usize| -> Result { + let rest = b.get(*pos..).ok_or_else(|| err(name, "truncated header"))?; + let end = rest.iter().take(MAX_HEADER).position(|&c| c == b'\n').ok_or_else(|| err(name, "truncated header"))?; + let s = String::from_utf8_lossy(rest.get(..end).unwrap_or_default()).trim_end_matches('\r').to_string(); + *pos += end + 1; + Ok(s) + }; + loop { + let l = line(&mut pos)?; + if l.is_empty() { + break; + } + if let Some(f) = l.strip_prefix("FORMAT=") { + match f.trim() { + "32-bit_rle_rgbe" => {} + "32-bit_rle_xyze" => return Err(err(name, "the XYZE colour format is not supported")), + other => return Err(err(name, &format!("unknown format {other}"))), + } + } + if pos > MAX_HEADER { + return Err(err(name, "header too long")); + } + } + let res = line(&mut pos)?; + let t: Vec<&str> = res.split_whitespace().collect(); + let &[a1, n1, a2, n2] = t.as_slice().first_chunk::<4>().ok_or_else(|| err(name, "bad resolution line"))?; + let ((x1, p1), (x2, p2)) = (axis(a1).ok_or_else(|| err(name, "bad resolution line"))?, axis(a2).ok_or_else(|| err(name, "bad resolution line"))?); + if x1 == x2 { + return Err(err(name, "resolution line names one axis twice")); + } + let n1: usize = n1.parse().map_err(|_| err(name, "bad resolution line"))?; + let n2: usize = n2.parse().map_err(|_| err(name, "bad resolution line"))?; + let (w, h) = if x1 { (n1, n2) } else { (n2, n1) }; + if w == 0 || h == 0 || (w as u64).saturating_mul(h as u64) > MAX_PIXELS { + return Err(err(name, "picture size out of range")); + } + // a scan line takes at least a few bits of file, so a header cannot ask for more than the data has + if (b.len().saturating_sub(pos) as u64).saturating_mul(4096) < (w as u64).saturating_mul(h as u64) { + return Err(err(name, "truncated pixel data")); + } + let mut px = vec![1f32; w * h * 4]; + let mut scan = vec![[0u8; 4]; n2]; + for i in 0..n1 { + scanline(name, b, &mut pos, &mut scan)?; + for (j, &p) in scan.iter().enumerate() { + // the first axis counts scan lines, the second runs along one + let (x, y) = if x1 { + (if p1 { i } else { w - 1 - i }, if p2 { h - 1 - j } else { j }) + } else { + (if p2 { j } else { w - 1 - j }, if p1 { h - 1 - i } else { i }) + }; + let [r, g, bl] = rgbe(p); + if let Some(o) = px.get_mut((y * w + x) * 4..(y * w + x) * 4 + 3) { + o.copy_from_slice(&[r, g, bl]); + } + } + } + let mut frame = VideoFrame::rgba_f32(w as u32, h as u32, px); + frame.color = LINEAR_709; + Ok(frame) +} + +#[cfg(test)] +mod tests; diff --git a/crates/media/src/still/hdr/tests.rs b/crates/media/src/still/hdr/tests.rs new file mode 100644 index 00000000..3e0b5564 --- /dev/null +++ b/crates/media/src/still/hdr/tests.rs @@ -0,0 +1,134 @@ +use super::*; + +fn file(res: &str, body: &[u8]) -> Vec { + let mut v = format!("#?RADIANCE\n# test\nFORMAT=32-bit_rle_rgbe\nEXPOSURE=1.0\n\n{res}\n").into_bytes(); + v.extend_from_slice(body); + v +} + +fn px(f: &VideoFrame) -> Vec { + match &f.data { + filmcraft_frame::PixelData::RgbaF32(p) => p.to_vec(), + _ => panic!("not float"), + } +} + +/// New-style RLE of one scan line, as Radiance's own writer does: per channel, runs of 3+ equal bytes. +fn rle_line(line: &[[u8; 4]]) -> Vec { + let n = line.len(); + let mut out = vec![2, 2, (n >> 8) as u8, n as u8]; + for c in 0..4 { + let ch: Vec = line.iter().map(|p| p[c]).collect(); + let mut i = 0; + while i < n { + let run = ch[i..].iter().take(127).take_while(|&&b| b == ch[i]).count(); + if run >= 3 { + out.extend([128 + run as u8, ch[i]]); + i += run; + } else { + let mut j = i + 1; + while j < n && j - i < 128 && !(j + 2 < n && ch[j] == ch[j + 1] && ch[j] == ch[j + 2]) { + j += 1; + } + out.push((j - i) as u8); + out.extend_from_slice(&ch[i..j]); + i = j; + } + } + } + out +} + +#[test] +fn rgbe_values() { + assert_eq!(rgbe([0, 0, 0, 0]), [0.0; 3]); + assert_eq!(rgbe([128, 128, 128, 129]), [1.0; 3]); + assert_eq!(rgbe([128, 64, 0, 136]), [128.0, 64.0, 0.0]); + assert_eq!(rgbe([128, 128, 128, 128])[0], 0.5); + assert!(rgbe([255, 255, 255, 255])[0].is_finite()); +} + +#[test] +fn flat_top_to_bottom() { + let body = [[10u8, 20, 30, 136], [1, 2, 3, 136], [4, 5, 6, 137], [7, 8, 9, 135]].concat(); + let f = decode("a.hdr", &file("-Y 2 +X 2", &body)).unwrap(); + assert_eq!((f.width, f.height), (2, 2)); + let p = px(&f); + assert_eq!(&p[0..4], &[10.0, 20.0, 30.0, 1.0]); + assert_eq!(&p[4..8], &[1.0, 2.0, 3.0, 1.0]); + assert_eq!(&p[8..12], &[8.0, 10.0, 12.0, 1.0]); // exponent 137: scale 2 + assert_eq!(&p[12..16], &[3.5, 4.0, 4.5, 1.0]); // exponent 135: scale 1/2 +} + +#[test] +fn orientations() { + // 3 wide, 2 high, pixel value = its index in the file + let body: Vec = (0..6u8).flat_map(|i| [i, 0, 0, 136]).collect(); + let red = |res: &str| -> Vec { + let f = decode("a.hdr", &file(res, &body)).unwrap(); + px(&f).chunks(4).map(|p| p[0]).collect() + }; + assert_eq!(red("-Y 2 +X 3"), [0., 1., 2., 3., 4., 5.]); + assert_eq!(red("+Y 2 +X 3"), [3., 4., 5., 0., 1., 2.]); + assert_eq!(red("-Y 2 -X 3"), [2., 1., 0., 5., 4., 3.]); + // scan lines are columns: file index = x * 2 + (row, top first for -Y) + assert_eq!(red("+X 3 -Y 2"), [0., 2., 4., 1., 3., 5.]); + assert_eq!(red("-X 3 +Y 2"), [5., 3., 1., 4., 2., 0.]); +} + +#[test] +fn run_length_lines() { + let w = 40usize; + let rows: Vec> = (0..3).map(|y| (0..w).map(|x| if x < 20 { [100, 50, 25, 130] } else { [(x * 5 + y) as u8, 7, 9, 131] }).collect()).collect(); + let body: Vec = rows.iter().flat_map(|r| rle_line(r)).collect(); + let f = decode("a.hdr", &file(&format!("-Y 3 +X {w}"), &body)).unwrap(); + let p = px(&f); + for (y, r) in rows.iter().enumerate() { + for (x, q) in r.iter().enumerate() { + let want = rgbe(*q); + assert_eq!(&p[(y * w + x) * 4..(y * w + x) * 4 + 3], &want, "pixel {x},{y}"); + } + } +} + +#[test] +fn old_style_runs() { + // one pixel, then (1,1,1,3) repeats it 3 times, then a (1,1,1,1) pair repeats 1<<0 then 1<<8 -> clipped to the line + let body = [[5u8, 6, 7, 136], [1, 1, 1, 3], [9, 9, 9, 136]].concat(); + let f = decode("a.hdr", &file("-Y 1 +X 5", &body)).unwrap(); + let p = px(&f); + assert_eq!(p[0], 5.0); + assert_eq!(p[12], 5.0); // fourth pixel is still a repeat + assert_eq!(p[16], 9.0); +} + +#[test] +fn hostile_input_is_an_error_not_a_panic() { + let ok = file("-Y 2 +X 2", &[0; 16]); + assert!(decode("a.hdr", &ok).is_ok()); + for cut in 0..ok.len() - 1 { + // a cut anywhere is an error (the header or the pixel data is incomplete), never a panic + assert!(decode("a.hdr", &ok[..cut]).is_err(), "{cut}"); + } + assert!(decode("a.hdr", &file("-Y 2 +X 2", &[0; 8])).is_err()); + assert!(decode("a.hdr", &file("-Y 99999999 +X 99999999", &[0; 16])).is_err()); + assert!(decode("a.hdr", &file("-Y 0 +X 4", &[])).is_err()); + assert!(decode("a.hdr", &file("-Y 2 -Y 2", &[0; 16])).is_err()); + assert!(decode("a.hdr", &file("junk", &[0; 16])).is_err()); + assert!(decode("a.hdr", b"#?RADIANCE\nFORMAT=32-bit_rle_xyze\n\n-Y 1 +X 1\n\0\0\0\0").is_err()); + // an RLE line announcing the wrong width, a run past the end, a run at the start of a flat line + assert!(decode("a.hdr", &file("-Y 1 +X 10", &[2, 2, 0, 11, 0])).is_err()); + assert!(decode("a.hdr", &file("-Y 1 +X 10", &[2, 2, 0, 10, 200, 1])).is_err()); + assert!(decode("a.hdr", &file("-Y 1 +X 3", &[1, 1, 1, 9, 0, 0, 0, 0])).is_err()); + assert!(decode("a.hdr", b"#?RADIANCE").is_err()); +} + +#[test] +fn opens_as_a_float_still() { + let body = [[128u8, 128, 128, 136]; 4].concat(); + let s = still("a.hdr", &file("-Y 2 +X 2", &body)).unwrap(); + let info = crate::MediaSource::info(&s); + assert_eq!(info.video.as_ref().map(|v| (v.width, v.height, v.codec.as_str())), Some((2, 2, "HDR"))); + let src = crate::open_bytes("a.hdr", file("-Y 2 +X 2", &body).into(), &[]).unwrap(); + assert_eq!(src.info().kind, MediaKind::Still); +} diff --git a/crates/media/tests/hdr_oracle.rs b/crates/media/tests/hdr_oracle.rs new file mode 100644 index 00000000..d52d58ec --- /dev/null +++ b/crates/media/tests/hdr_oracle.rs @@ -0,0 +1,99 @@ +//! Radiance HDR stills against ffmpeg (an external test oracle): we write RGBE files in the flat and +//! new run-length forms, and our decode must give the floats ffmpeg's decoder gives. + +use std::path::Path; + +use filmcraft_frame::PixelData; +use filmcraft_media::FrameRequest; + +const W: usize = 70; // 8..=32767, so scan lines may use the new run-length form +const H: usize = 9; + +/// A deterministic picture with long runs (so RLE has runs) and noisy stretches (literals). +fn picture() -> Vec<[u8; 4]> { + let mut s = 0x1234_5678u32; + (0..W * H) + .map(|i| { + s = s.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + let (x, y) = (i % W, i / W); + if x < 30 { + [200, 100, 50, 120 + y as u8] + } else if i % 3 == 0 { + [(s >> 24) as u8, 0, 255, 0] // exponent 0: black + } else { + [(s >> 24) as u8, (s >> 16) as u8, (s >> 8) as u8, 100 + (s & 31) as u8] + } + }) + .collect() +} + +fn rle_line(line: &[[u8; 4]]) -> Vec { + let n = line.len(); + let mut out = vec![2, 2, (n >> 8) as u8, n as u8]; + for c in 0..4 { + let ch: Vec = line.iter().map(|p| p[c]).collect(); + let mut i = 0; + while i < n { + let run = ch[i..].iter().take(127).take_while(|&&b| b == ch[i]).count(); + if run >= 3 { + out.extend([128 + run as u8, ch[i]]); + i += run; + } else { + let mut j = i + 1; + while j < n && j - i < 128 && !(j + 2 < n && ch[j] == ch[j + 1] && ch[j] == ch[j + 2]) { + j += 1; + } + out.push((j - i) as u8); + out.extend_from_slice(&ch[i..j]); + i = j; + } + } + } + out +} + +fn write(path: &Path, res: &str, body: &[u8]) { + let mut v = format!("#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n\n{res}\n").into_bytes(); + v.extend_from_slice(body); + std::fs::write(path, v).unwrap(); +} + +fn check(ff: &Path, name: &str, res: &str, body: &[u8]) { + let dir = filmcraft_testkit::fixtures::fixtures_dir("media/hdr"); + let path = dir.join(name); + write(&path, res, body); + let out = std::process::Command::new(ff).args(["-v", "error", "-i"]).arg(&path).args(["-f", "rawvideo", "-pix_fmt", "gbrpf32le", "-"]).output().unwrap(); + assert!(out.status.success(), "ffmpeg decode of {name}: {}", String::from_utf8_lossy(&out.stderr)); + let v: Vec = out.stdout.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)).collect(); + let n = W * H; + assert_eq!(v.len(), 3 * n, "{name}: ffmpeg frame size"); + + let src = filmcraft_media::open_bytes(name, std::fs::read(&path).unwrap().into(), &[]).unwrap(); + let f = src.video_frame(FrameRequest::full(filmcraft_time::Tick::ZERO)).unwrap(); + assert_eq!((f.width as usize, f.height as usize), (W, H)); + let PixelData::RgbaF32(p) = &f.data else { panic!("HDR frames are float") }; + let mut worst = 0f32; + for i in 0..n { + // gbrp planes: 0 = G, 1 = B, 2 = R + for (c, plane) in [2, 0, 1].into_iter().enumerate() { + let (a, b) = (p[i * 4 + c], v[plane * n + i]); + worst = worst.max((a - b).abs() / b.abs().max(1e-6)); + } + assert_eq!(p[i * 4 + 3], 1.0); + } + // ffmpeg's decode and ours differ only by float rounding of the same formula + assert!(worst == 0.0, "{name}: worst relative difference {worst}"); +} + +#[test] +fn matches_ffmpeg() { + let ff = filmcraft_testkit::require_ffmpeg!(); + let pic = picture(); + let rows: Vec<&[[u8; 4]]> = pic.chunks(W).collect(); + let flat: Vec = pic.iter().flatten().copied().collect(); + check(&ff, "flat.hdr", &format!("-Y {H} +X {W}"), &flat); + let rle: Vec = rows.iter().flat_map(|r| rle_line(r)).collect(); + check(&ff, "rle.hdr", &format!("-Y {H} +X {W}"), &rle); + // Other axis orders are covered by unit tests: ffmpeg reads every file as top-down, which the + // format description does not (+Y starts at the bottom row). +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 1b495659..53db7293 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -59,7 +59,8 @@ only). "Tested" names the evidence. | **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% | +| **Radiance HDR** | read | yes: RGBE, flat, old and new run-length scan lines, every axis order, kept as linear float (#1407); no XYZE, EXPOSURE / PRIMARIES lines ignored | — | 70% | +| AI / EPS, ICO, PTL | read | no | — | 0% | ## Audio files diff --git a/docs/gaps.md b/docs/gaps.md index 9b2a2796..1666be84 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -180,7 +180,8 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** substream, 1–6 blocks per syncframe, spectral extension. MP1 / MP2 (MPEG audio Layer I / II): own decoder (#809) in raw files, MP4 and MOV (#800), MPEG-TS / PS and Matroska, including the bit rate / mode combinations ISO/IEC 11172-3 disallows but ffmpeg writes (its mono default is - 384 kbit/s). OpenEXR stills and image + 384 kbit/s). Radiance HDR (`.hdr`) stills and image sequences (#1407): RGBE as linear float, flat + and run-length scan lines, any axis order. OpenEXR stills and image sequences (#828): scan-line, uncompressed / RLE / ZIPS / ZIP, half / float / uint, RGB(A) or luminance, decoded to linear float frames and bit-exact against ffmpeg. - **Missing:** free-format MPEG audio, MP1 in Matroska (`A_MPEG/L1`), E-AC-3 adaptive hybrid @@ -190,7 +191,7 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** incl. R3D NE, ARRIRAW, Sony RAW / X-OCN, Canon RAW, BRAW, ProRes RAW; #345, #383), JPEG 2000 / JPEG XS MXF, the rest of CinemaDNG (lossy / JPEG XL DNG, MXF-wrapped, opcode lists, Raw settings; DNG frames and folders import since #1065); stills: PSD, EXR beyond scan-line RLE / ZIP (PIZ, PXR24, B44, DWA, - tiled, multi-part, deep), HEIF / HEIC, Radiance HDR, + tiled, multi-part, deep), HEIF / HEIC, AI / EPS, and the rarer 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). From 8457e7bf31d22cabfba1ecc00c2d635e28bb9c01 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 21:19:59 +0200 Subject: [PATCH 37/73] #1322: the Properties panel scrolls Problem: the Properties panel laid its sections out in a plain child ui, so when the panel was shorter than its content (Transform, Crop, Audio, the speed button) the lower part was cut off and the wheel did nothing. Fix: put everything below the clip header in a vertical ScrollArea, keeping the rows 14 px clear of the scrollbar so the keyframe arrows at the row end stay clickable. The speed button gets the automation id `properties.speed`. Regression test properties_scroll_ui scrolls a short panel and checks the speed button moves; it fails without the fix. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/panels/effect_controls.rs | 202 ++++++++++--------- crates/ui-egui/tests/properties_scroll_ui.rs | 66 ++++++ 2 files changed, 174 insertions(+), 94 deletions(-) create mode 100644 crates/ui-egui/tests/properties_scroll_ui.rs diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 1750022a..1d554712 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -849,116 +849,130 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } open }; - let eff_idx = |id: &str| it.effects.iter().position(|e| e.effect == id); - let val = |eid: &str, p: &str| it.effect(eid).and_then(|e| e.param(p)).map(|p| p.value_at(mt)); - if kind == TrackKind::Video { - if section(&mut bui, app, "Transform", eff_idx("motion").map(|i| (i, clip.0)), &mut actions) { - for (label, p, unit, speed, range) in [ - (tl!("Position"), "position", "", 1.0, (-100_000.0, 100_000.0)), - (tl!("Anchor point"), "anchor", "", 1.0, (-100_000.0, 100_000.0)), - (tl!("Scale"), "scale", " %", 0.5, (0.0, 10000.0)), - (tl!("Rotation"), "rotation", " °", 0.5, (-36000.0, 36000.0)), - ] { - let r = row(&mut bui, label); + // the sections scroll: a short panel would otherwise cut Audio and the speed button off (#1322) + egui::ScrollArea::vertical().id_salt("props-scroll").auto_shrink([false, false]).show(&mut bui, |bui| { + // keep the rows (and their keyframe arrows) clear of the scrollbar at the panel edge + bui.set_max_width((bui.available_width() - 14.0).max(1.0)); + let eff_idx = |id: &str| it.effects.iter().position(|e| e.effect == id); + let val = |eid: &str, p: &str| it.effect(eid).and_then(|e| e.param(p)).map(|p| p.value_at(mt)); + if kind == TrackKind::Video { + if section(bui, app, "Transform", eff_idx("motion").map(|i| (i, clip.0)), &mut actions) { + for (label, p, unit, speed, range) in [ + (tl!("Position"), "position", "", 1.0, (-100_000.0, 100_000.0)), + (tl!("Anchor point"), "anchor", "", 1.0, (-100_000.0, 100_000.0)), + (tl!("Scale"), "scale", " %", 0.5, (0.0, 10000.0)), + (tl!("Rotation"), "rotation", " °", 0.5, (-36000.0, 36000.0)), + ] { + let r = row(bui, label); + let mut vui = bui.new_child( + egui::UiBuilder::new() + .max_rect(Rect::from_min_max(pos2(r.min.x + 150.0, r.min.y + 4.0), pos2(r.max.x - PROPS_VALUE_R, r.max.y - 4.0))) + .layout(egui::Layout::left_to_right(egui::Align::Center)), + ); + match val("motion", p) { + Some(ParamValue::Vec2(v)) => { + let (_, nx) = crate::widgets::hot_number(&mut vui, egui::Id::new(("pp", p, "x", clip.0)), v.x, speed, range, 0, " X", &t); + vui.add_space(10.0); + let (_, ny) = crate::widgets::hot_number(&mut vui, egui::Id::new(("pp", p, "y", clip.0)), v.y, speed, range, 0, " Y", &t); + if nx.is_some() || ny.is_some() { + actions.push(( + "effects.setParam".into(), + json!({"clip": clip.0, "effect": "motion", "param": p, "value": [nx.unwrap_or(v.x), ny.unwrap_or(v.y)]}), + )); + } + } + Some(ParamValue::Float(v)) => { + let (_, nv) = crate::widgets::hot_number( + &mut vui, + egui::Id::new(("pp", p, clip.0)), + v, + speed, + range, + if p == "scale" { 0 } else { 1 }, + unit, + &t, + ); + if let Some(nv) = nv { + actions.push(("effects.setParam".into(), json!({"clip": clip.0, "effect": "motion", "param": p, "value": nv}))); + } + } + _ => {} + } + properties_nav(app, bui, r, clip, &it, mt, "motion", p, &mut actions); + } + let r = row(bui, tl!("Opacity")); let mut vui = bui.new_child( egui::UiBuilder::new() .max_rect(Rect::from_min_max(pos2(r.min.x + 150.0, r.min.y + 4.0), pos2(r.max.x - PROPS_VALUE_R, r.max.y - 4.0))) .layout(egui::Layout::left_to_right(egui::Align::Center)), ); - match val("motion", p) { - Some(ParamValue::Vec2(v)) => { - let (_, nx) = crate::widgets::hot_number(&mut vui, egui::Id::new(("pp", p, "x", clip.0)), v.x, speed, range, 0, " X", &t); - vui.add_space(10.0); - let (_, ny) = crate::widgets::hot_number(&mut vui, egui::Id::new(("pp", p, "y", clip.0)), v.y, speed, range, 0, " Y", &t); - if nx.is_some() || ny.is_some() { - actions.push(( - "effects.setParam".into(), - json!({"clip": clip.0, "effect": "motion", "param": p, "value": [nx.unwrap_or(v.x), ny.unwrap_or(v.y)]}), - )); - } + if let Some(ParamValue::Float(v)) = val("opacity", "opacity") { + let (_, nv) = crate::widgets::hot_number(&mut vui, egui::Id::new(("pp-op", clip.0)), v, 0.5, (0.0, 100.0), 0, " %", &t); + if let Some(nv) = nv { + actions.push(("effects.setParam".into(), json!({"clip": clip.0, "effect": "opacity", "param": "opacity", "value": nv}))); } - Some(ParamValue::Float(v)) => { - let (_, nv) = - crate::widgets::hot_number(&mut vui, egui::Id::new(("pp", p, clip.0)), v, speed, range, if p == "scale" { 0 } else { 1 }, unit, &t); - if let Some(nv) = nv { - actions.push(("effects.setParam".into(), json!({"clip": clip.0, "effect": "motion", "param": p, "value": nv}))); - } - } - _ => {} } - properties_nav(app, &mut bui, r, clip, &it, mt, "motion", p, &mut actions); + properties_nav(app, bui, r, clip, &it, mt, "opacity", "opacity", &mut actions); } - let r = row(&mut bui, tl!("Opacity")); + let crop = eff_idx("crop"); + if section(bui, app, "Crop", crop.map(|i| (i, clip.0)), &mut actions) { + for (label, p) in [(tl!("Left"), "left"), (tl!("Top"), "top"), (tl!("Right"), "right"), (tl!("Bottom"), "bottom")] { + let r = row(bui, label); + let mut vui = bui.new_child( + egui::UiBuilder::new() + .max_rect(Rect::from_min_max(pos2(r.min.x + 150.0, r.min.y + 4.0), pos2(r.max.x - PROPS_VALUE_R, r.max.y - 4.0))) + .layout(egui::Layout::left_to_right(egui::Align::Center)), + ); + let v = val("crop", p).and_then(|v| v.as_f64()).unwrap_or(0.0); + let (_, nv) = crate::widgets::hot_number(&mut vui, egui::Id::new(("pp-crop", p, clip.0)), v, 0.2, (0.0, 100.0), 1, " %", &t); + if let Some(nv) = nv { + if crop.is_none() { + actions.push(("effects.apply".into(), json!({"clips": [clip.0], "effect": "crop"}))); + } + actions.push(("effects.setParam".into(), json!({"clip": clip.0, "effect": "crop", "param": p, "value": nv}))); + } + properties_nav(app, bui, r, clip, &it, mt, "crop", p, &mut actions); + } + } + } + if let Some((clip, it, _)) = clips.iter().find(|c| c.2 == TrackKind::Audio) + && section(bui, app, "Audio", it.effects.iter().position(|e| e.effect == "volume").map(|i| (i, clip.0)), &mut actions) + { + let clip = *clip; + let mt = it.source_time_at(ph.clamp(it.start, (it.end() - Tick(1)).max(it.start))); + let val = |eid: &str, p: &str| it.effect(eid).and_then(|e| e.param(p)).map(|p| p.value_at(mt)); + let r = row(bui, tl!("Level")); let mut vui = bui.new_child( egui::UiBuilder::new() .max_rect(Rect::from_min_max(pos2(r.min.x + 150.0, r.min.y + 4.0), pos2(r.max.x - PROPS_VALUE_R, r.max.y - 4.0))) .layout(egui::Layout::left_to_right(egui::Align::Center)), ); - if let Some(ParamValue::Float(v)) = val("opacity", "opacity") { - let (_, nv) = crate::widgets::hot_number(&mut vui, egui::Id::new(("pp-op", clip.0)), v, 0.5, (0.0, 100.0), 0, " %", &t); - if let Some(nv) = nv { - actions.push(("effects.setParam".into(), json!({"clip": clip.0, "effect": "opacity", "param": "opacity", "value": nv}))); - } + let v = val("volume", "level").and_then(|v| v.as_f64()).unwrap_or(0.0); + let (_, nv) = crate::widgets::hot_number(&mut vui, egui::Id::new(("pp-vol", clip.0)), v, 0.1, (-96.0, 15.0), 1, " dB", &t); + if let Some(nv) = nv { + actions.push(("effects.setParam".into(), json!({"clip": clip.0, "effect": "volume", "param": "level", "value": nv}))); } - properties_nav(app, &mut bui, r, clip, &it, mt, "opacity", "opacity", &mut actions); - } - let crop = eff_idx("crop"); - if section(&mut bui, app, "Crop", crop.map(|i| (i, clip.0)), &mut actions) { - for (label, p) in [(tl!("Left"), "left"), (tl!("Top"), "top"), (tl!("Right"), "right"), (tl!("Bottom"), "bottom")] { - let r = row(&mut bui, label); - let mut vui = bui.new_child( - egui::UiBuilder::new() - .max_rect(Rect::from_min_max(pos2(r.min.x + 150.0, r.min.y + 4.0), pos2(r.max.x - PROPS_VALUE_R, r.max.y - 4.0))) - .layout(egui::Layout::left_to_right(egui::Align::Center)), - ); - let v = val("crop", p).and_then(|v| v.as_f64()).unwrap_or(0.0); - let (_, nv) = crate::widgets::hot_number(&mut vui, egui::Id::new(("pp-crop", p, clip.0)), v, 0.2, (0.0, 100.0), 1, " %", &t); - if let Some(nv) = nv { - if crop.is_none() { - actions.push(("effects.apply".into(), json!({"clips": [clip.0], "effect": "crop"}))); - } - actions.push(("effects.setParam".into(), json!({"clip": clip.0, "effect": "crop", "param": p, "value": nv}))); - } - properties_nav(app, &mut bui, r, clip, &it, mt, "crop", p, &mut actions); + properties_nav(app, bui, r, clip, it, mt, "volume", "level", &mut actions); + let r = row(bui, tl!("Pan")); + let mut vui = bui.new_child( + egui::UiBuilder::new() + .max_rect(Rect::from_min_max(pos2(r.min.x + 150.0, r.min.y + 4.0), pos2(r.max.x - PROPS_VALUE_R, r.max.y - 4.0))) + .layout(egui::Layout::left_to_right(egui::Align::Center)), + ); + let v = val("panner", "balance").and_then(|v| v.as_f64()).unwrap_or(0.0); + let (_, nv) = crate::widgets::hot_number(&mut vui, egui::Id::new(("pp-pan", clip.0)), v, 0.5, (-100.0, 100.0), 0, "", &t); + if let Some(nv) = nv { + actions.push(("effects.setParam".into(), json!({"clip": clip.0, "effect": "panner", "param": "balance", "value": nv}))); } } - } - if let Some((clip, it, _)) = clips.iter().find(|c| c.2 == TrackKind::Audio) - && section(&mut bui, app, "Audio", it.effects.iter().position(|e| e.effect == "volume").map(|i| (i, clip.0)), &mut actions) - { - let clip = *clip; - let mt = it.source_time_at(ph.clamp(it.start, (it.end() - Tick(1)).max(it.start))); - let val = |eid: &str, p: &str| it.effect(eid).and_then(|e| e.param(p)).map(|p| p.value_at(mt)); - let r = row(&mut bui, tl!("Level")); - let mut vui = bui.new_child( - egui::UiBuilder::new() - .max_rect(Rect::from_min_max(pos2(r.min.x + 150.0, r.min.y + 4.0), pos2(r.max.x - PROPS_VALUE_R, r.max.y - 4.0))) - .layout(egui::Layout::left_to_right(egui::Align::Center)), - ); - let v = val("volume", "level").and_then(|v| v.as_f64()).unwrap_or(0.0); - let (_, nv) = crate::widgets::hot_number(&mut vui, egui::Id::new(("pp-vol", clip.0)), v, 0.1, (-96.0, 15.0), 1, " dB", &t); - if let Some(nv) = nv { - actions.push(("effects.setParam".into(), json!({"clip": clip.0, "effect": "volume", "param": "level", "value": nv}))); - } - properties_nav(app, &mut bui, r, clip, it, mt, "volume", "level", &mut actions); - let r = row(&mut bui, tl!("Pan")); - let mut vui = bui.new_child( - egui::UiBuilder::new() - .max_rect(Rect::from_min_max(pos2(r.min.x + 150.0, r.min.y + 4.0), pos2(r.max.x - PROPS_VALUE_R, r.max.y - 4.0))) - .layout(egui::Layout::left_to_right(egui::Align::Center)), - ); - let v = val("panner", "balance").and_then(|v| v.as_f64()).unwrap_or(0.0); - let (_, nv) = crate::widgets::hot_number(&mut vui, egui::Id::new(("pp-pan", clip.0)), v, 0.5, (-100.0, 100.0), 0, "", &t); - if let Some(nv) = nv { - actions.push(("effects.setParam".into(), json!({"clip": clip.0, "effect": "panner", "param": "balance", "value": nv}))); - } - } - // speed footer - bui.add_space(12.0); - let (br, bresp) = bui.allocate_exact_size(vec2(118.0, 26.0), Sense::click()); - bui.painter().rect_filled(br, 4.0, if bresp.hovered() { t.hover } else { t.panel_bg }); - bui.painter().rect_stroke(br, 4.0, Stroke::new(1.0, t.separator), egui::StrokeKind::Inside); - bui.painter().text(br.center(), Align2::CENTER_CENTER, tlf!("Speed {n}%", n = format!("{:.0}", it.speed * 100.0)), Tokens::ui(12.0), t.text); + // speed footer + bui.add_space(12.0); + let (br, bresp) = bui.allocate_exact_size(vec2(118.0, 26.0), Sense::click()); + bui.painter().rect_filled(br, 4.0, if bresp.hovered() { t.hover } else { t.panel_bg }); + bui.painter().rect_stroke(br, 4.0, Stroke::new(1.0, t.separator), egui::StrokeKind::Inside); + bui.painter().text(br.center(), Align2::CENTER_CENTER, tlf!("Speed {n}%", n = format!("{:.0}", it.speed * 100.0)), Tokens::ui(12.0), t.text); + app.auto.add("properties.speed", br, "Speed"); + }); run(app, ui.ctx(), actions); } diff --git a/crates/ui-egui/tests/properties_scroll_ui.rs b/crates/ui-egui/tests/properties_scroll_ui.rs new file mode 100644 index 00000000..ae2614cd --- /dev/null +++ b/crates/ui-egui/tests/properties_scroll_ui.rs @@ -0,0 +1,66 @@ +//! The Properties panel scrolls (#1322): with a short panel the Audio section and the speed button +//! sit below the fold, and the mouse wheel has to bring them into view. + +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 frames(&mut self, n: usize) { + for _ in 0..n { + let ctx = self.harness.ctx.clone(); + let mut raw = std::mem::take(self.harness.input_mut()); + eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw); + *self.harness.input_mut() = raw; + self.harness.step(); + } + } + fn ok(&mut self, method: &str, params: Value) -> Value { + let (req, reply) = ControlRequest::new(method, params.clone()); + self.tx.send(req).expect("control channel"); + for _ in 0..600 { + self.frames(1); + if let Ok(r) = reply.try_recv() { + assert_eq!(r["ok"], true, "{method} {params}: {r}"); + return r["result"].clone(); + } + } + panic!("no reply to {method} {params}"); + } + fn top(&mut self, id: &str) -> Option { + self.frames(2); + let v = self.ok("ui.elements", json!({"prefix": id})); + let e = v.as_array()?.iter().find(|e| e["id"] == id)?; + e["rect"].as_array()?.get(1)?.as_f64() + } +} + +#[test] +fn properties_panel_scrolls_to_its_lower_sections() { + 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, 560.0)).with_max_steps(10_000).build_eframe(move |_cc| app); + let mut d = Driver { harness, tx }; + d.frames(4); + let seq = d.ok("engine.execute", json!({"command": "sequence.inspect", "params": {}})); + let clip = seq["video"][0]["items"][0]["clip"].as_u64().expect("first video clip"); + d.ok("engine.execute", json!({"command": "timeline.select", "params": {"clips": [clip]}})); + d.ok("ui.panel.show", json!({"panel": "Properties"})); + d.frames(3); + let before = d.top("properties.speed").expect("the speed button is laid out"); + d.ok("ui.scroll", json!({"id": "panel.Properties", "dy": -2000.0})); + d.frames(3); + let after = d.top("properties.speed").expect("the speed button is still laid out"); + assert!(after < before - 20.0, "the wheel scrolls the panel: {before} -> {after}"); +} From cf9ad667326c072fd244ffbd5bd3d8046f1244c1 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 22:07:25 +0200 Subject: [PATCH 38/73] #1429: MPEG-1 program stream cut inside a pack header no longer panics Problem: packet_len accepted an MPEG-1 pack header (b[4]>>4==2) with only 6 bytes available, but scan reads the SCR from head[4..9]. A file ending inside the last pack header (00 00 01 BA 21 00 00) panicked with "range end index 9 out of range". Fix: packet_len requires the full 12-byte MPEG-1 pack header; a shorter tail is not a packet and takes the normal resync path. The MPEG-2 branch already needs 14 bytes (b.get(13)) before scan reads 10, so it is fine. Tests: truncate a valid MPEG-1 and MPEG-2 program stream at every byte length, plus the minimal 7-byte input; both panicked before the fix. Co-Authored-By: Claude Sonnet 5.5 --- crates/mpegts/src/ps.rs | 3 +++ crates/mpegts/src/tests.rs | 44 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/crates/mpegts/src/ps.rs b/crates/mpegts/src/ps.rs index 7813e8d6..28a63530 100644 --- a/crates/mpegts/src/ps.rs +++ b/crates/mpegts/src/ps.rs @@ -94,6 +94,9 @@ fn packet_len(b: &[u8]) -> Option { let stuff = (*b.get(13)? & 7) as usize; Some(14 + stuff) } else if b[4] >> 4 == 2 { + // MPEG-1 pack header: start code, 5-byte SCR, 3-byte mux rate. A file cut inside + // it is not a pack header yet (`scan` reads the SCR from the first 9 bytes). + b.get(..12)?; Some(12) } else { None diff --git a/crates/mpegts/src/tests.rs b/crates/mpegts/src/tests.rs index 65eb083e..01c6e886 100644 --- a/crates/mpegts/src/tests.rs +++ b/crates/mpegts/src/tests.rs @@ -276,6 +276,50 @@ fn truncation_and_mutation_never_panic() { } } +/// Opens `data` and reads every unit; errors are fine, panics are not. +fn open_and_read_all(data: &[u8]) { + let data = data.to_vec(); + if let Ok(f) = open(&data) { + for (s, st) in f.streams.iter().enumerate() { + for i in 0..st.units.len() { + let _ = f.read_unit(&data, s, i); + } + } + } +} + +#[test] +fn program_stream_cut_inside_a_pack_header_never_panics() { + // MPEG-1 pack header cut after the 4-byte start code + 3 bytes (once panicked in `scan`). + open_and_read_all(&[0, 0, 1, 0xBA, 0x21, 0, 0]); + for n in 4..=12 { + let mpeg1 = [0u8, 0, 1, 0xBA, 0x21, 0, 1, 0, 1, 0x80, 0, 1]; + open_and_read_all(&mpeg1[..n]); + } +} + +#[test] +fn program_stream_truncation_at_every_length_never_panics() { + let frames: Vec> = (0..3).map(|k| mpeg2_frame(if k == 0 { 1 } else { 2 }, k as u16, false, 300)).collect(); + let es: Vec = frames.concat(); + for mpeg1 in [false, true] { + let mut out = Vec::new(); + for (k, chunk) in es.chunks(500).enumerate() { + if mpeg1 { + out.extend([0, 0, 1, 0xBA, 0x21, 0, 1, 0, 1, 0x80, 0, 1]); + } else { + out.extend([0, 0, 1, 0xBA, 0x44, 0, 4, 0, 4, 1, 1, 0x89, 0xC3, 0xF8]); + } + out.extend(pes(0xE0, Some(3600 * k as i64), None, chunk, true)); + } + out.extend([0, 0, 1, 0xB9]); + assert!(open(&out).is_ok(), "the untruncated stream opens (mpeg1: {mpeg1})"); + for cut in 0..=out.len() { + open_and_read_all(&out[..cut]); + } + } +} + #[test] fn timestamps_unwrap_across_the_33_bit_wrap() { let mut u = Unwrap::default(); From 393284d78f90c0c4337d3a9a4fd9965a68a27284 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 22:09:27 +0200 Subject: [PATCH 39/73] #1430: mpegts: resync inside the loaded block, bad adaptation field is not lost sync Problem: in ts::scan every packet-parse failure re-read a whole 4096-packet block from an offset that advanced about one packet. packet() returned None both for a bad sync byte and for an adaptation_field_length that overruns the packet, so aligned packets with a bad adaptation field cost one ~770 KB read each (a 564 KB file made ByteSource::read_at fetch 846 MB). The fallback offset (`pos + got`) also overshot by k packets, skipping unread data, and the five-packet sync window only looked at the first packet_size start positions. Fix: packet() distinguishes Bad::Sync from Bad::Adaptation. A bad adaptation field means "no payload, grid intact": count a warning and go on with the next packet. A real sync loss is repaired inside the loaded block (resync(): first start whose five sync bytes line up, any distance); the block is refilled from the file at the exact new offset only when it is used up. read() skips a bad-adaptation packet instead of ending the unit. Tests: read-volume tests with a counting ByteSource (bad adaptation fields, sync byte every 189 bytes), and a resync test with a garbage prefix and mid-stream garbage around the block boundary for 188- and 192-byte packets. Co-Authored-By: Claude Sonnet 5.5 --- crates/mpegts/src/tests.rs | 96 ++++++++++++++++++++++++++++++++ crates/mpegts/src/ts.rs | 110 ++++++++++++++++++++++++++++--------- 2 files changed, 180 insertions(+), 26 deletions(-) diff --git a/crates/mpegts/src/tests.rs b/crates/mpegts/src/tests.rs index 65eb083e..5e0fa617 100644 --- a/crates/mpegts/src/tests.rs +++ b/crates/mpegts/src/tests.rs @@ -286,3 +286,99 @@ fn timestamps_unwrap_across_the_33_bit_wrap() { assert_eq!(u.unwrap(near + 1000), near + 1000); assert_eq!(u.unwrap(4200), (1 << 33) + 4200); } + +/// A `ByteSource` that counts the bytes requested through `read_at`. +struct Counting<'a> { + data: &'a [u8], + read: std::cell::Cell, +} + +impl ByteSource for Counting<'_> { + fn len(&self) -> u64 { + self.data.len() as u64 + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> { + self.read.set(self.read.get() + buf.len() as u64); + self.data.read_at(offset, buf) + } +} + +#[test] +fn bad_adaptation_fields_do_not_amplify_reads() { + // aligned packets whose adaptation_field_length (255) overruns the packet: the packet grid is + // intact, so this must cost one pass over the file, not one block read per packet + let mut data = Vec::new(); + for _ in 0..3000 { + data.extend([0x47, 0x11, 0x00, 0x30]); + data.extend(std::iter::repeat_n(0xFF, 184)); + } + let src = Counting { data: &data, read: Default::default() }; + let f = open(&src).unwrap(); + assert!(f.warnings.iter().any(|w| w.contains("adaptation field")), "{:?}", f.warnings); + assert!(!f.warnings.iter().any(|w| w.contains("lost packet sync")), "{:?}", f.warnings); + let read = src.read.get(); + assert!(read <= 4 * data.len() as u64, "read {read} bytes of a {}-byte file", data.len()); +} + +#[test] +fn garbage_without_sync_does_not_amplify_reads() { + // a sync byte every 189 bytes: five-in-a-row never lines up, every 0x47 is a false candidate + let mut data = Vec::new(); + for _ in 0..3000 { + data.extend([0x47]); + data.extend(std::iter::repeat_n(0x11, 188)); + } + let head: Vec = (0..188 * 8).map(|i| if i % 188 == 0 { 0x47 } else { 0 }).collect(); + let mut all = head; + all.extend(data); + let src = Counting { data: &all, read: Default::default() }; + let f = open(&src).unwrap(); + assert!(f.warnings.iter().any(|w| w.contains("lost packet sync")), "{:?}", f.warnings); + let read = src.read.get(); + assert!(read <= 4 * all.len() as u64, "read {read} bytes of a {}-byte file", all.len()); +} + +#[test] +fn transport_stream_resyncs_after_garbage() { + const BLOCK_PACKETS: usize = 4096; + for m2ts in [false, true] { + let ps = if m2ts { 192 } else { 188 }; + // garbage in front of the stream (shorter than 8 packets) and, at a few places around the + // first block boundary, between two frames; lengths that are not a multiple of the packet + for (delta, glen) in [(0usize, 100usize), (1, 37), (2, 188), (3, 301), (5, 5), (8, 1000)] { + let edge = (BLOCK_PACKETS - delta) * ps; + let mut mux = TsMux::new(m2ts); + mux.out.extend([0x11, 0x47, 0x22]); // garbage prefix with a lone 0x47 + mux.psi(&[(0x02, 0x1011)]); + let mut expect = Vec::new(); + let mut placed = false; + let mut k = 0usize; + while k < 3000 || !placed { + if !placed && mux.out.len() >= edge { + // garbage between two PES packets, with lone sync bytes inside + let mut g: Vec = (0..glen).map(|i| 0x10 + (i % 7) as u8).collect(); + if let Some(b) = g.get_mut(3) { + *b = 0x47; + } + mux.out.extend(g); + placed = true; + } + let f = mpeg2_frame(if k == 0 { 1 } else { 2 }, (k % 1000) as u16, false, 40 + k % 300); + mux.pes(0x1011, &pes(0xE0, Some(90_000 + 3600 * k as i64), None, &f, false)); + expect.push(f); + k += 1; + } + assert!(mux.out.len() > (BLOCK_PACKETS + 4) * ps, "stream must cross a block boundary"); + let data = mux.out; + let f = open(&data).unwrap(); + assert!(f.warnings.iter().any(|w| w.contains("lost packet sync")), "{:?}", f.warnings); + let v = f.find(Kind::Video, |_| true).unwrap(); + let s = &f.streams[v]; + assert_eq!(s.units.len(), expect.len(), "m2ts {m2ts} delta {delta} glen {glen}"); + for (i, fr) in expect.iter().enumerate() { + assert_eq!(&f.read_unit(&data, v, i).unwrap(), fr, "unit {i} (m2ts {m2ts} delta {delta} glen {glen})"); + assert_eq!(s.units[i].pts, Some(90_000 + 3600 * i as i64)); + } + } + } +} diff --git a/crates/mpegts/src/ts.rs b/crates/mpegts/src/ts.rs index 98b092f7..d0f395e7 100644 --- a/crates/mpegts/src/ts.rs +++ b/crates/mpegts/src/ts.rs @@ -29,9 +29,19 @@ struct Packet<'a> { payload: &'a [u8], } -fn packet(p: &[u8]) -> Option> { +/// Why a packet could not be parsed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Bad { + /// No sync byte (or too short): the packet grid is lost. + Sync, + /// Sync byte present but the adaptation field overruns the packet: the grid is intact, the + /// packet carries nothing usable. + Adaptation, +} + +fn packet(p: &[u8]) -> std::result::Result, Bad> { if p.len() < 188 || p[0] != 0x47 { - return None; + return Err(Bad::Sync); } let pusi = p[1] & 0x40 != 0; let pid = (((p[1] & 0x1F) as u16) << 8) | p[2] as u16; @@ -44,7 +54,7 @@ fn packet(p: &[u8]) -> Option> { let al = p[4] as usize; start = 5 + al; if start > 188 { - return None; + return Err(Bad::Adaptation); } if al > 0 { let flags = p[5]; @@ -57,7 +67,7 @@ fn packet(p: &[u8]) -> Option> { } } let has_payload = afc & 1 != 0; - Some(Packet { pusi, pid, cc, has_payload, discontinuity, pcr, payload: if has_payload { &p[start..188] } else { &[] } }) + Ok(Packet { pusi, pid, cc, has_payload, discontinuity, pcr, payload: if has_payload { &p[start..188] } else { &[] } }) } /// Reassembles a PES header that may span packets; yields each packet's elementary-stream bytes. @@ -216,6 +226,39 @@ fn first_sync(head: &[u8], packet_size: usize, sync_offset: usize) -> Option (usize, bool) { + let mut i = from; + while i + packet_size <= block.len() { + if block.get(i + sync_offset) == Some(&0x47) { + let mut aligned = true; + let mut complete = true; + for k in 1..5 { + match block.get(i + sync_offset + k * packet_size) { + Some(&0x47) => {} + Some(_) => { + aligned = false; + break; + } + None => { + complete = false; + break; + } + } + } + if aligned { + return (i, !complete && !at_end); + } + } + i += 1; + } + (i, true) +} + pub(crate) fn scan(src: &S, packet_size: usize, sync_offset: usize) -> Result { let len = src.len(); let mut head = vec![0u8; packet_size * 8]; @@ -236,30 +279,39 @@ pub(crate) fn scan(src: &S, packet_size: usize, sync_off let (mut lost_sync, mut cc_errors, mut crc_errors) = (0u64, 0u64, 0u64); const BLOCK: usize = 4096; let mut block = vec![0u8; BLOCK * packet_size]; + let mut bad_adaptation = 0u64; while off + packet_size as u64 <= len { let got = read_some(src, off, &mut block)?; - let whole = got / packet_size; - if whole == 0 { + if got < packet_size { break; } - let mut k = 0; - while k < whole { - let pos = off + (k * packet_size) as u64; - let raw = &block[k * packet_size..(k + 1) * packet_size]; - let Some(pk) = packet(&raw[sync_offset..]) else { - // lost sync: find the next offset where packets line up again - lost_sync += 1; - let rest = &block[k * packet_size + 1..got]; - match first_sync(rest, packet_size, sync_offset).or_else(|| rest.iter().position(|&b| b == 0x47).map(|p| p.saturating_sub(sync_offset))) { - Some(s) => { - off = pos + 1 + s as u64; - } - None => off = pos + got as u64, + let at_end = off + got as u64 >= len; + // `p`: byte index of the next packet in `block`. Packets are parsed in place; a lost + // sync is repaired inside the loaded block, and the block is refilled from the file + // (at `off + p`) only when it is used up, so the bytes read stay close to the file size. + let mut p = 0usize; + while p + packet_size <= got { + let pos = off + p as u64; + let raw = &block[p..p + packet_size]; + let pk = match packet(&raw[sync_offset..]) { + Ok(pk) => pk, + Err(Bad::Adaptation) => { + // the packet grid is intact: this packet just has no usable payload + bad_adaptation += 1; + p += packet_size; + continue; + } + Err(Bad::Sync) => { + lost_sync += 1; + let (next, exhausted) = resync(&block[..got], p + 1, packet_size, sync_offset, at_end); + p = next; + if exhausted { + break; + } + continue; } - k = usize::MAX; - break; }; - k += 1; + p += packet_size; if let Some(pcr) = pk.pcr { // 33-bit base × 300 wraps at 2^33·300 let wrap = (1i64 << 33) * 300; @@ -362,13 +414,15 @@ pub(crate) fn scan(src: &S, packet_size: usize, sync_off } e.split.feed(pos, bytes); } - if k != usize::MAX { - off += (whole * packet_size) as u64; - } + // `p >= 1` here (the loop ran at least once or `resync` moved past the failed packet) + off += p.max(1) as u64; } if lost_sync > 0 { warnings.push(format!("lost packet sync {lost_sync} time(s)")); } + if bad_adaptation > 0 { + warnings.push(format!("{bad_adaptation} packet(s) with an invalid adaptation field")); + } if cc_errors > 0 { warnings.push(format!("{cc_errors} continuity counter error(s)")); } @@ -406,7 +460,11 @@ pub(crate) fn read(src: &S, packet_size: usize, sync_off } for k in 0..whole { let raw = &block[k * packet_size..(k + 1) * packet_size]; - let Some(pk) = packet(&raw[sync_offset..]) else { return Ok(()) }; + let pk = match packet(&raw[sync_offset..]) { + Ok(pk) => pk, + Err(Bad::Sync) => return Ok(()), + Err(Bad::Adaptation) => continue, + }; if pk.pid != pid { continue; } From 542300e91175e0ff3c12f8fc952c1e1628ecc569 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:26:58 +0200 Subject: [PATCH 40/73] fix(media-browser): drag every selected file to the Timeline, not just one (#1360) --- crates/ui-egui/src/panels/media_browser.rs | 47 +++++++++++++++++----- crates/ui-egui/tests/project_panel_ui.rs | 34 ++++++++++++++++ 2 files changed, 70 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/panels/media_browser.rs b/crates/ui-egui/src/panels/media_browser.rs index 14529a29..180f44a3 100644 --- a/crates/ui-egui/src/panels/media_browser.rs +++ b/crates/ui-egui/src/panels/media_browser.rs @@ -447,26 +447,38 @@ fn entry_interactions(app: &mut FilmcraftApp, ui: &egui::Ui, resp: &egui::Respon }); } -/// Dragging a file imports it (the selection when the file is in it) and carries the new item, so -/// the Project panel, Timeline and monitors accept it; a drop elsewhere undoes the import. +/// Dragging a file imports it (the selection when the file is in it) and carries its items, so +/// the Project panel, Timeline and monitors accept them; a drop elsewhere undoes the import. fn start_file_drag(app: &mut FilmcraftApp, ui: &egui::Ui, e: &Entry) { let paths: Vec = if app.session.browser.selection.contains(&e.path) { app.session.browser.selection.clone() } else { vec![e.path.clone()] }; - // files already in the project are dragged as they are - if let Some(i) = mb::item_for_path(&app.session, &e.path) { - crate::panels::start_drag_item(ui, i); - return; - } + // files already in the project are dragged as they are; only the others are imported + let known: Vec = paths.iter().filter_map(|p| mb::item_for_path(&app.session, p)).collect(); + let fresh: Vec = paths.iter().filter(|p| mb::item_for_path(&app.session, p).is_none()).cloned().collect(); let before = app.session.history.undo.len(); - let Ok(r) = app.session.execute("mediaBrowser.import", json!({"paths": paths})) else { return }; - let items: Vec = r["items"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default(); - if let Some(first) = items.first() { - crate::panels::start_drag_item(ui, ItemId(*first)); + let mut items: Vec = Vec::new(); + if !fresh.is_empty() { + let Ok(r) = app.session.execute("mediaBrowser.import", json!({"paths": fresh})) else { return }; + items = r["items"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default(); + } + let all = file_drag_items(&known, &items); + let Some(first) = all.first().copied() else { return }; + // the drop targets place the whole Project selection when the dragged item is one of it + if all.len() > 1 { + app.session.state.project_selection = all; + } + crate::panels::start_drag_item(ui, first); + if !items.is_empty() { let pos = ui.ctx().pointer_latest_pos().unwrap_or_default(); let after = app.session.history.undo.len(); with_cache(ui.ctx(), |c| c.drag = Some((items, after.max(before), pos))); } } +/// Every item a Media Browser drag carries: the selected files already in the project, then the newly imported ones. +fn file_drag_items(known: &[ItemId], imported: &[u64]) -> Vec { + known.iter().copied().chain(imported.iter().map(|i| ItemId(*i))).collect() +} + /// After a file drag ends: keep the import when it landed on a panel that takes items. fn finish_drag(app: &mut FilmcraftApp, ui: &egui::Ui) { let Some((items, hist, _)) = with_cache(ui.ctx(), |c| c.drag.clone()) else { return }; @@ -854,3 +866,16 @@ pub fn dialogs(app: &mut FilmcraftApp, ctx: &egui::Context) { } app.ui.media_browser.edit_columns = if close || crate::widgets::escape_closes(ctx) { None } else { Some(cols) }; } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dragging_several_files_carries_all_of_them() { + let all = file_drag_items(&[ItemId(7)], &[11, 12]); + assert_eq!(all, vec![ItemId(7), ItemId(11), ItemId(12)]); + assert_eq!(file_drag_items(&[], &[3]), vec![ItemId(3)]); + assert!(file_drag_items(&[], &[]).is_empty()); + } +} diff --git a/crates/ui-egui/tests/project_panel_ui.rs b/crates/ui-egui/tests/project_panel_ui.rs index 8a76e0a4..eb6bb3ec 100644 --- a/crates/ui-egui/tests/project_panel_ui.rs +++ b/crates/ui-egui/tests/project_panel_ui.rs @@ -760,6 +760,40 @@ fn media_browser_drag_imports_into_the_project_panel() { let _ = std::fs::remove_dir_all(&dir); } +/// Dragging several selected Media Browser files onto the Timeline (#1360) places every one of +/// them, whether it was already in the project or is imported by the drag. +#[test] +fn media_browser_drag_places_every_selected_file() { + let dir = media_dir("drag-many"); + 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 listed = d.exec("mediaBrowser.list", json!({})); + let path = |name: &str| listed["entries"].as_array().unwrap().iter().find(|e| e["name"] == json!(name)).unwrap()["path"].clone(); + let (a, b) = (path("a-interview.wav"), path("b-roll.wav")); + // one file is already in the project, the other is imported by the drag + d.exec("mediaBrowser.import", json!({"paths": [a]})); + d.exec("mediaBrowser.select", json!({"paths": [a, b]})); + d.frames(2); + let n0 = d.app().session.project.items.len(); + let clips = |d: &mut Driver| { + d.app().session.active_sequence().map(|q| q.video_tracks.iter().chain(&q.audio_tracks).map(|t| t.items.len()).sum::()).unwrap_or(0) + }; + let c0 = clips(&mut d); + let e = d.rect("mediaBrowser.entry.b-roll.wav"); + let tl = d.rect("panel.Timeline"); + d.ok("ui.drag", json!({"from": {"x": e[0] + 40.0, "y": e[1] + e[3] / 2.0}, "to": {"x": tl[0] + tl[2] * 0.6, "y": tl[1] + tl[3] * 0.75}, "steps": 20})); + d.frames(6); + assert_eq!(d.app().session.project.items.len(), n0 + 1, "only the new file is imported"); + assert_eq!(clips(&mut d), c0 + 2, "both files are placed; status: {}", d.app().ui.status); + let _ = std::fs::remove_dir_all(&dir); +} + /// Dragging one of several selected Project items onto the Timeline places all of them, back to /// back, as one undo step; an item outside the selection goes alone. #[test] From 4d58c67745cfec2147ee04326a352e0c1f6490dd Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 22:48:18 +0200 Subject: [PATCH 41/73] fix(bitstream): map se(v) through the 64-bit codeNum so i32::MIN round-trips (fixes #1428) --- crates/bitstream/src/lib.rs | 63 +++++++++++++++++++++++++++++++------ 1 file changed, 54 insertions(+), 9 deletions(-) diff --git a/crates/bitstream/src/lib.rs b/crates/bitstream/src/lib.rs index 8b708f9d..1bdb5126 100644 --- a/crates/bitstream/src/lib.rs +++ b/crates/bitstream/src/lib.rs @@ -117,8 +117,8 @@ impl<'a> BitReader<'a> { Ok(()) } - /// Unsigned Exp-Golomb `ue(v)`. - pub fn read_ue(&mut self) -> Result { + /// Raw Exp-Golomb codeNum, up to 32 leading zeros (so below 2^33). + fn read_code_num(&mut self) -> Result { let mut zeros = 0u32; while !self.read_bit()? { zeros += 1; @@ -130,14 +130,20 @@ impl<'a> BitReader<'a> { return Ok(0); } let v = self.read_bits_u64(zeros)?; - let r = (1u64 << zeros) - 1 + v; - u32::try_from(r).map_err(|_| BitError::InvalidExpGolomb) + Ok((1u64 << zeros) - 1 + v) + } + + /// Unsigned Exp-Golomb `ue(v)`. + pub fn read_ue(&mut self) -> Result { + u32::try_from(self.read_code_num()?).map_err(|_| BitError::InvalidExpGolomb) } /// Signed Exp-Golomb `se(v)`. pub fn read_se(&mut self) -> Result { - let k = self.read_ue()? as i64; - Ok(if k & 1 == 1 { ((k + 1) / 2) as i32 } else { -((k / 2) as i32) }) + // codeNum 2^32 is -2^31, so the mapping is done before narrowing to 32 bits. + let k = self.read_code_num()? as i64; + let v = if k & 1 == 1 { (k + 1) / 2 } else { -(k / 2) }; + i32::try_from(v).map_err(|_| BitError::InvalidExpGolomb) } /// Truncated Exp-Golomb `te(v)` with range `max`. @@ -196,7 +202,11 @@ impl BitWriter { self.write_bits(b as u32, 1); } pub fn write_ue(&mut self, v: u32) { - let x = v as u64 + 1; + self.write_code_num(u64::from(v)); + } + /// Exp-Golomb codeNum up to 2^32 (the code of `se(v)` -2^31). + fn write_code_num(&mut self, v: u64) { + let x = v + 1; let len = 64 - x.leading_zeros(); let zeros = len - 1; for _ in 0..zeros { @@ -210,8 +220,8 @@ impl BitWriter { } } pub fn write_se(&mut self, v: i32) { - let k = if v > 0 { (v as u32) * 2 - 1 } else { (-(v as i64) as u32) * 2 }; - self.write_ue(k); + let m = u64::from(v.unsigned_abs()) * 2; + self.write_code_num(if v > 0 { m - 1 } else { m }); } pub fn bit_len(&self) -> usize { self.buf.len() * 8 + self.nbits as usize @@ -377,6 +387,41 @@ mod tests { } } + #[test] + fn signed_exp_golomb_extremes_roundtrip() { + let vals = [0, 1, -1, i32::MAX, i32::MIN]; + let mut w = BitWriter::new(); + for v in vals { + w.write_se(v); + } + let b = w.finish(); + let mut r = BitReader::new(&b); + for v in vals { + assert_eq!(r.read_se(), Ok(v)); + } + } + + #[test] + fn signed_exp_golomb_reads_minimum_from_spec_bits() { + // 32 zero bits, 1, then the 32-bit suffix 1: codeNum = 2^32 -> -2^31. + let bytes = [0, 0, 0, 0, 0x80, 0, 0, 0, 0x80]; + assert_eq!(BitReader::new(&bytes).read_se(), Ok(i32::MIN)); + } + + #[test] + fn signed_exp_golomb_rejects_positive_overflow() { + // 32 zero bits, 1, then 32 zero bits: codeNum = 2^32 - 1 -> +2^31, not an i32. + let bytes = [0, 0, 0, 0, 0x80, 0, 0, 0, 0]; + assert_eq!(BitReader::new(&bytes).read_se(), Err(BitError::InvalidExpGolomb)); + } + + #[test] + fn unsigned_exp_golomb_keeps_u32_range() { + // codeNum = 2^32 is a valid se(v) code but does not fit ue(v)'s u32. + let bytes = [0, 0, 0, 0, 0x80, 0, 0, 0, 0x80]; + assert_eq!(BitReader::new(&bytes).read_ue(), Err(BitError::InvalidExpGolomb)); + } + #[test] fn emulation_prevention() { let raw = [0, 0, 1, 0, 0, 0, 5, 0, 0, 3]; From 5b76035c3dd852e9a28089a0fc3ae0dee7e8b71d Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 23:02:55 +0200 Subject: [PATCH 42/73] #1450: drop the unread PesHeader.stream_id and packet_length Nothing outside one test read them. No behaviour change; the existing header tests cover parse. Co-Authored-By: Claude Sonnet 5.5 --- crates/mpegts/src/pes.rs | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/crates/mpegts/src/pes.rs b/crates/mpegts/src/pes.rs index 8e5fea36..73ed5914 100644 --- a/crates/mpegts/src/pes.rs +++ b/crates/mpegts/src/pes.rs @@ -3,9 +3,6 @@ /// A parsed PES header. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct PesHeader { - pub stream_id: u8, - /// PES_packet_length (bytes after the length field; 0 = unbounded, video in TS). - pub packet_length: usize, /// Bytes from the packet start code to the first payload byte. pub header_len: usize, /// Raw 33-bit timestamps. @@ -28,10 +25,8 @@ pub(crate) fn parse(b: &[u8]) -> Option { if b.len() < 6 || b[0] != 0 || b[1] != 0 || b[2] != 1 { return None; } - let stream_id = b[3]; - let packet_length = u16::from_be_bytes([b[4], b[5]]) as usize; - if plain_stream(stream_id) { - return Some(PesHeader { stream_id, packet_length, header_len: 6, pts: None, dts: None }); + if plain_stream(b[3]) { + return Some(PesHeader { header_len: 6, pts: None, dts: None }); } if b.len() >= 9 && b[6] & 0xC0 == 0x80 { // ISO/IEC 13818-1 syntax @@ -47,7 +42,7 @@ pub(crate) fn parse(b: &[u8]) -> Option { dts = Some(timestamp(&b[14..19])); } } - return Some(PesHeader { stream_id, packet_length, header_len, pts, dts }); + return Some(PesHeader { header_len, pts, dts }); } // ISO/IEC 11172-1 syntax: stuffing, STD buffer, timestamps let mut p = 6; @@ -78,7 +73,7 @@ pub(crate) fn parse(b: &[u8]) -> Option { 0 if c == 0x0F => p += 1, _ => return None, } - Some(PesHeader { stream_id, packet_length, header_len: p, pts, dts }) + Some(PesHeader { header_len: p, pts, dts }) } #[cfg(test)] @@ -98,7 +93,7 @@ mod tests { b.extend(ts_bytes(1, dts)); b.push(0xAA); let h = parse(&b).unwrap(); - assert_eq!((h.stream_id, h.header_len, h.pts, h.dts), (0xE0, 19, Some(pts), Some(dts))); + assert_eq!((h.header_len, h.pts, h.dts), (19, Some(pts), Some(dts))); // MPEG-1: stuffing, STD buffer, PTS only let mut b = vec![0, 0, 1, 0xC0, 0, 20, 0xFF, 0xFF, 0x40, 0x20]; b.extend(ts_bytes(2, 90_000)); From 1990bde6d3e964034e2378345108016fa9f38a6b Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 23:02:55 +0200 Subject: [PATCH 43/73] #1450: fix two stale doc comments in mpegts Unit.pos for transport streams is a byte offset (scan stores off + k * packet_size), not a packet index; frame_bytes carried the doc line of frame_info above its own. Co-Authored-By: Claude Sonnet 5.5 --- crates/mpegts/src/es.rs | 1 - crates/mpegts/src/lib.rs | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/mpegts/src/es.rs b/crates/mpegts/src/es.rs index 88f03796..0b96e148 100644 --- a/crates/mpegts/src/es.rs +++ b/crates/mpegts/src/es.rs @@ -174,7 +174,6 @@ pub struct FrameInfo { const ADTS_RATES: [u32; 13] = [96_000, 88_200, 64_000, 48_000, 44_100, 32_000, 24_000, 22_050, 16_000, 12_000, 11_025, 8_000, 7_350]; -/// Parse the frame header at the start of `b` (MPEG audio, ADTS, AC-3, E-AC-3). /// Bytes of the audio frame whose header starts `b` (MPEG audio, ADTS, LATM, AC-3, E-AC-3), or /// `None` without a valid header there. For containers that store frames in chunks that need not /// follow them (AVI): the frames are found again with it. diff --git a/crates/mpegts/src/lib.rs b/crates/mpegts/src/lib.rs index 4330ebcb..4092b6b5 100644 --- a/crates/mpegts/src/lib.rs +++ b/crates/mpegts/src/lib.rs @@ -218,7 +218,7 @@ pub struct Unit { /// No other unit depends on it (B pictures of MPEG-1/2 video, non-reference H.264/HEVC). pub disposable: bool, pub size: u32, - /// TS: index of the packet holding the first byte; PS: byte offset of its PES packet. + /// TS: byte offset of the packet holding the first byte; PS: byte offset of its PES packet. pub(crate) pos: u64, /// Offset of the first byte within that packet's elementary-stream payload. pub(crate) offset: u32, From da13964a820d5d77d6e14da997cb877407cce417 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 23:04:44 +0200 Subject: [PATCH 44/73] #1450: table-driven CRC-32 for PSI sections One table lookup per byte instead of eight shift steps. Proved equal to the old bitwise loop (kept in the test as the reference) for every single byte, random inputs of 0..4096 bytes, the CRC-32/MPEG-2 check value and the section-plus-own-CRC-is-zero property. Co-Authored-By: Claude Sonnet 5.5 --- crates/mpegts/src/lib.rs | 2 ++ crates/mpegts/src/ts.rs | 23 ++++++++++++--- crates/mpegts/src/work_tests.rs | 50 +++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 crates/mpegts/src/work_tests.rs diff --git a/crates/mpegts/src/lib.rs b/crates/mpegts/src/lib.rs index 4092b6b5..cb62b66e 100644 --- a/crates/mpegts/src/lib.rs +++ b/crates/mpegts/src/lib.rs @@ -345,3 +345,5 @@ impl Unwrap { #[cfg(test)] mod tests; +#[cfg(test)] +mod work_tests; diff --git a/crates/mpegts/src/ts.rs b/crates/mpegts/src/ts.rs index 98b092f7..b77691bb 100644 --- a/crates/mpegts/src/ts.rs +++ b/crates/mpegts/src/ts.rs @@ -6,14 +6,29 @@ use crate::es::Splitter; use crate::pes::{self, PesHeader}; use crate::{ByteSource, Codec, Error, File, Format, Result, Stream, StreamId, Unit, Unwrap, read_some}; +/// Byte-wise table for the MSB-first CRC-32 (polynomial 0x04C11DB7): entry `i` is the CRC register +/// after shifting the byte `i` through eight bit steps. +const CRC_TABLE: [u32; 256] = { + let mut t = [0u32; 256]; + let mut i = 0; + while i < 256 { + let mut crc = (i as u32) << 24; + let mut bit = 0; + while bit < 8 { + crc = if crc & 0x8000_0000 != 0 { (crc << 1) ^ 0x04C1_1DB7 } else { crc << 1 }; + bit += 1; + } + t[i] = crc; + i += 1; + } + t +}; + /// CRC-32 of PSI sections (MSB-first, polynomial 0x04C11DB7, initial value all ones; Annex A). pub(crate) 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 = (crc << 8) ^ CRC_TABLE[((crc >> 24) as u8 ^ b) as usize]; } crc } diff --git a/crates/mpegts/src/work_tests.rs b/crates/mpegts/src/work_tests.rs new file mode 100644 index 00000000..01d2244a --- /dev/null +++ b/crates/mpegts/src/work_tests.rs @@ -0,0 +1,50 @@ +//! Equality and counting tests for the redundant-work removals: the old implementation is kept +//! here as the reference, the new one must give the same results. + +use super::*; + +/// The bitwise CRC-32 that `ts::crc32_mpeg` replaced. +fn crc32_bitwise(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 +} + +/// Deterministic xorshift bytes. +fn noise(seed: u32, n: usize) -> Vec { + let mut x = seed | 1; + (0..n) + .map(|_| { + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + (x >> 8) as u8 + }) + .collect() +} + +#[test] +fn table_crc_equals_the_bitwise_crc() { + // the standard check value of CRC-32/MPEG-2 + assert_eq!(ts::crc32_mpeg(b"123456789"), 0x0376_E6E7); + assert_eq!(ts::crc32_mpeg(&[]), 0xFFFF_FFFF); + for b in 0..=255u8 { + assert_eq!(ts::crc32_mpeg(&[b]), crc32_bitwise(&[b])); + } + for n in [0usize, 1, 2, 3, 4, 5, 15, 16, 17, 183, 184, 1023, 1024, 4096] { + for seed in 1..6u32 { + let d = noise(seed * 7919 + n as u32, n); + assert_eq!(ts::crc32_mpeg(&d), crc32_bitwise(&d), "len {n} seed {seed}"); + } + } + // a section followed by its own CRC checks to zero + let mut sec = noise(5, 40); + let crc = ts::crc32_mpeg(&sec); + sec.extend(crc.to_be_bytes()); + assert_eq!(ts::crc32_mpeg(&sec), 0); +} From afc54a5f38176c41f6e2976137747ac2f19fdb76 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 23:04:44 +0200 Subject: [PATCH 45/73] #1450: do not zero-fill the program stream window past the end of the file A window refill resized the buffer to at least 1 MiB although read_some can only fill what the file still holds. Near the end of the file, where the previous window was shorter, that zeroed about a megabyte for every packet whose length ran past the end. The buffer is now capped at the bytes left. Window contents are unchanged: proved against the old refill for many lengths, positions and sizes, and the zeroed byte count is asserted (at most 1000 against about 500 MiB for the old refill in the tail scenario). Co-Authored-By: Claude Sonnet 5.5 --- crates/mpegts/src/ps.rs | 82 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 80 insertions(+), 2 deletions(-) diff --git a/crates/mpegts/src/ps.rs b/crates/mpegts/src/ps.rs index 7813e8d6..7a6595a9 100644 --- a/crates/mpegts/src/ps.rs +++ b/crates/mpegts/src/ps.rs @@ -12,17 +12,27 @@ struct Win<'a, S: ByteSource + ?Sized> { src: &'a S, buf: Vec, start: u64, + /// Bytes zero-filled by window refills (tests count them). + #[cfg(test)] + zero_filled: usize, } impl<'a, S: ByteSource + ?Sized> Win<'a, S> { fn new(src: &'a S) -> Self { - Self { src, buf: Vec::new(), start: 0 } + Self { src, buf: Vec::new(), start: 0, #[cfg(test)] zero_filled: 0 } } /// The bytes [pos, pos + n) (fewer at the end of the file). fn get(&mut self, pos: u64, n: usize) -> Result<&[u8]> { let have = pos >= self.start && pos + n as u64 <= self.start + self.buf.len() as u64; if !have { - let want = n.max(1 << 20); + // never more than the file still holds past `pos`: `read_some` would not fill it, and + // zeroing a megabyte for every packet near the end of the file is wasted work + let remaining = self.src.len().saturating_sub(pos); + let want = (n.max(1 << 20) as u64).min(remaining) as usize; + #[cfg(test)] + { + self.zero_filled += want.saturating_sub(self.buf.len()); + } self.buf.resize(want, 0); let got = read_some(self.src, pos, &mut self.buf)?; self.buf.truncate(got); @@ -266,3 +276,71 @@ pub(crate) fn read(src: &S, st: &Stream, u: &Unit, out: } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + /// The window refill before the zero-fill cap: always `max(n, 1 MiB)` bytes, counted when the + /// buffer has to grow. + struct OldWin<'a> { + src: &'a [u8], + buf: Vec, + start: u64, + zero_filled: usize, + } + + impl OldWin<'_> { + fn get(&mut self, pos: u64, n: usize) -> Vec { + let have = pos >= self.start && pos + n as u64 <= self.start + self.buf.len() as u64; + if !have { + let want = n.max(1 << 20); + self.zero_filled += want.saturating_sub(self.buf.len()); + self.buf.resize(want, 0); + let got = read_some(self.src, pos, &mut self.buf).unwrap(); + self.buf.truncate(got); + self.start = pos; + } + let i = (pos - self.start) as usize; + let end = (i + n).min(self.buf.len()); + self.buf[i.min(end)..end].to_vec() + } + } + + fn data(len: usize) -> Vec { + (0..len).map(|i| (i * 31 + i / 7) as u8).collect() + } + + #[test] + fn capped_window_returns_what_the_uncapped_window_returned() { + for len in [0usize, 1, 31, 32, 33, 1000, (1 << 20) - 1, 1 << 20, (1 << 20) + 5, (3 << 20) + 17] { + let d = data(len); + let mut new = Win::new(d.as_slice()); + let mut old = OldWin { src: &d, buf: Vec::new(), start: 0, zero_filled: 0 }; + let len64 = len as u64; + let mut positions = vec![0, 1, 31, 32, 1 << 19, 1 << 20, (1 << 20) - 1, (1 << 20) + 1, 2 << 20, len64.saturating_sub(40), len64.saturating_sub(1), len64, len64 + 1, len64 + 100]; + positions.extend((0..40).map(|k| len64.saturating_sub(100) + k * 3)); + for &pos in &positions { + for n in [0usize, 1, 4, 32, 33, 1000, 65_541, 1 << 20, (1 << 20) + 1, 2 << 20] { + assert_eq!(new.get(pos, n).unwrap(), old.get(pos, n).as_slice(), "len {len} pos {pos} n {n}"); + } + } + } + } + + #[test] + fn misses_near_the_end_zero_at_most_the_bytes_that_are_left() { + let len = (2 << 20) + 1000; + let d = data(len); + let mut new = Win::new(d.as_slice()); + let mut old = OldWin { src: &d, buf: Vec::new(), start: 0, zero_filled: 0 }; + // a PES packet whose length runs past the end of the file, asked for once per packet + let first = (2u64 << 20) as usize; + for k in 0..500 { + let pos = (first + k) as u64; + assert_eq!(new.get(pos, 2000).unwrap(), old.get(pos, 2000).as_slice()); + } + assert!(new.zero_filled <= 1000, "zeroed {} bytes, only 1000 are left in the file", new.zero_filled); + assert!(old.zero_filled > 499 * ((1 << 20) - 1000), "the reference zeroes about a megabyte per miss: {}", old.zero_filled); + } +} From 5198059a7ce78715992e3683b5b68fdcb1b3e4a5 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 23:05:29 +0200 Subject: [PATCH 46/73] #1450: borrow program stream packets from the window instead of copying them scan and read copied the 32-byte head of every packet and every pack, PSM and PES packet (up to 64 KB) with to_vec before looking at it. They now borrow the window; the borrows end before the next window access. Output is unchanged: the new large-stream test (two streams, over 2 MiB, MPEG-1 and MPEG-2 packs, cuts inside the last packets) passes on the old and the new code, as do the existing round-trip and truncation tests. The allocation saving follows from the removed to_vec calls; it is not measured (the workspace forbids unsafe, so there is no counting allocator). Co-Authored-By: Claude Sonnet 5.5 --- crates/mpegts/src/ps.rs | 20 +++++------ crates/mpegts/src/work_tests.rs | 63 +++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 10 deletions(-) diff --git a/crates/mpegts/src/ps.rs b/crates/mpegts/src/ps.rs index 7a6595a9..b68d7f46 100644 --- a/crates/mpegts/src/ps.rs +++ b/crates/mpegts/src/ps.rs @@ -126,13 +126,13 @@ pub(crate) fn scan(src: &S, mpeg1: bool) -> Result let mut scr: Option<(i64, i64)> = None; let mut resyncs = 0u64; while pos + 4 <= len { - let head = win.get(pos, 32)?.to_vec(); - let Some(plen) = packet_len(&head) else { + let head = win.get(pos, 32)?; + let Some(plen) = packet_len(head) else { // resynchronise on the next pack header or PES start code resyncs += 1; let mut p = pos + 1; let found = loop { - let w = win.get(p, 1 << 16)?.to_vec(); + let w = win.get(p, 1 << 16)?; if w.len() < 4 { break None; } @@ -169,7 +169,7 @@ pub(crate) fn scan(src: &S, mpeg1: bool) -> Result scr = Some(scr.map_or((v, v), |(a, b): (i64, i64)| (a.min(v), b.max(v)))); } else if id == 0xBC { // program stream map: elementary stream id → stream_type - let body = win.get(pos, plen)?.to_vec(); + let body = win.get(pos, plen)?; if body.len() >= 12 { let info = u16::from_be_bytes([body[8], body[9]]) as usize; let mut i = 10 + info; @@ -184,11 +184,11 @@ pub(crate) fn scan(src: &S, mpeg1: bool) -> Result } } } else if id == 0xBD || (0xC0..=0xEF).contains(&id) || id == 0xFD { - let pkt = win.get(pos, plen)?.to_vec(); + let pkt = win.get(pos, plen)?; if pkt.len() < plen { break; // truncated } - if let Some(h) = pes::parse(&pkt) { + if let Some(h) = pes::parse(pkt) { let payload = &pkt[h.header_len.min(plen)..]; let (sub, skip) = if id == 0xBD { match payload.first() { @@ -249,14 +249,14 @@ pub(crate) fn read(src: &S, st: &Stream, u: &Unit, out: let len = src.len(); let mut win = Win::new(src); while out.len() < need && pos + 4 <= len { - let head = win.get(pos, 32)?.to_vec(); - let Some(plen) = packet_len(&head) else { + let head = win.get(pos, 32)?; + let Some(plen) = packet_len(head) else { // corrupt: stop at what we have return Ok(()); }; if head[3] == stream_id { - let pkt = win.get(pos, plen)?.to_vec(); - if let Some(h) = pes::parse(&pkt) { + let pkt = win.get(pos, plen)?; + if let Some(h) = pes::parse(pkt) { let payload = &pkt[h.header_len.min(pkt.len())..]; let matches = stream_id != 0xBD || payload.first().copied() == sub_id; if matches { diff --git a/crates/mpegts/src/work_tests.rs b/crates/mpegts/src/work_tests.rs index 01d2244a..64b276f3 100644 --- a/crates/mpegts/src/work_tests.rs +++ b/crates/mpegts/src/work_tests.rs @@ -48,3 +48,66 @@ fn table_crc_equals_the_bitwise_crc() { sec.extend(crc.to_be_bytes()); assert_eq!(ts::crc32_mpeg(&sec), 0); } + +/// MPEG-1 layer II frame, 48 kHz 192 kb/s (576 bytes), distinct per `k` and `salt`. +fn mp2_frame(k: usize, salt: u8) -> Vec { + let mut v = vec![0xFF, 0xFD, 0xA4, 0x00]; + v.extend((0..572).map(|i| (i as u8) ^ (k as u8) ^ salt)); + v +} + +fn pes_packet(stream_id: u8, payload: &[u8]) -> Vec { + let len = 3 + payload.len(); + let mut v = vec![0, 0, 1, stream_id, (len >> 8) as u8, len as u8, 0x80, 0, 0]; + v.extend_from_slice(payload); + v +} + +/// Two audio streams interleaved in maximal PES packets over more than three window refills, +/// with a pack header per PES packet; MPEG-2 or MPEG-1 pack headers. +fn big_program_stream(mpeg1: bool, frames: usize) -> (Vec, Vec>, Vec>) { + let a: Vec> = (0..frames).map(|k| mp2_frame(k, 0)).collect(); + let b: Vec> = (0..frames).map(|k| mp2_frame(k, 0x55)).collect(); + let (ea, eb) = (a.concat(), b.concat()); + let mut out = Vec::new(); + for (ca, cb) in ea.chunks(60_000).zip(eb.chunks(60_000)) { + for (id, chunk) in [(0xC0, ca), (0xC1, cb)] { + if mpeg1 { + out.extend([0, 0, 1, 0xBA, 0x21, 0, 1, 0, 1, 0x80, 0, 1]); + } else { + out.extend([0, 0, 1, 0xBA, 0x44, 0, 4, 0, 4, 1, 1, 0x89, 0xC3, 0xF8]); + } + out.extend(pes_packet(id, chunk)); + } + } + out.extend([0, 0, 1, 0xB9]); + (out, a, b) +} + +#[test] +fn program_stream_with_large_pes_packets_gives_back_every_frame() { + for mpeg1 in [false, true] { + let (data, a, b) = big_program_stream(mpeg1, 2000); // about 1.2 MB per stream + assert!(data.len() > 2 << 20, "more than the 1 MiB window twice"); + let f = open(&data).unwrap(); + assert_eq!(f.format, Format::Ps { mpeg1 }); + assert_eq!(f.streams.len(), 2); + for (s, frames) in f.streams.iter().zip([&a, &b]) { + assert_eq!(s.units.len(), frames.len()); + for (i, fr) in frames.iter().enumerate() { + assert_eq!(&f.read_unit(&data, f.streams.iter().position(|x| std::ptr::eq(x, s)).unwrap(), i).unwrap(), fr, "mpeg1 {mpeg1} unit {i}"); + } + } + // cut inside the last packets: never panics, and the units before the cut are intact + for cut in [data.len() - 1, data.len() - 4, data.len() - 30_000, data.len() - 61_000, 100_000, 1 << 20] { + let d = &data[..cut]; + let Ok(f) = open(d) else { continue }; + for (si, frames) in [&a, &b].into_iter().enumerate() { + let Some(s) = f.streams.get(si) else { continue }; + for i in 0..s.units.len().saturating_sub(1) { + assert_eq!(f.read_unit(d, si, i).unwrap(), frames[i], "cut {cut} stream {si} unit {i}"); + } + } + } + } +} From d034c58eddee39deeb2fcf191fe9a482fec75250 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 23:05:40 +0200 Subject: [PATCH 47/73] #1450: cargo fmt for the new mpegts tests and window field Co-Authored-By: Claude Sonnet 5.5 --- crates/mpegts/src/ps.rs | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/crates/mpegts/src/ps.rs b/crates/mpegts/src/ps.rs index b68d7f46..df5dd885 100644 --- a/crates/mpegts/src/ps.rs +++ b/crates/mpegts/src/ps.rs @@ -19,7 +19,13 @@ struct Win<'a, S: ByteSource + ?Sized> { impl<'a, S: ByteSource + ?Sized> Win<'a, S> { fn new(src: &'a S) -> Self { - Self { src, buf: Vec::new(), start: 0, #[cfg(test)] zero_filled: 0 } + Self { + src, + buf: Vec::new(), + start: 0, + #[cfg(test)] + zero_filled: 0, + } } /// The bytes [pos, pos + n) (fewer at the end of the file). fn get(&mut self, pos: u64, n: usize) -> Result<&[u8]> { @@ -318,7 +324,22 @@ mod tests { let mut new = Win::new(d.as_slice()); let mut old = OldWin { src: &d, buf: Vec::new(), start: 0, zero_filled: 0 }; let len64 = len as u64; - let mut positions = vec![0, 1, 31, 32, 1 << 19, 1 << 20, (1 << 20) - 1, (1 << 20) + 1, 2 << 20, len64.saturating_sub(40), len64.saturating_sub(1), len64, len64 + 1, len64 + 100]; + let mut positions = vec![ + 0, + 1, + 31, + 32, + 1 << 19, + 1 << 20, + (1 << 20) - 1, + (1 << 20) + 1, + 2 << 20, + len64.saturating_sub(40), + len64.saturating_sub(1), + len64, + len64 + 1, + len64 + 100, + ]; positions.extend((0..40).map(|k| len64.saturating_sub(100) + k * 3)); for &pos in &positions { for n in [0usize, 1, 4, 32, 33, 1000, 65_541, 1 << 20, (1 << 20) + 1, 2 << 20] { From e5b1ed90a9bf87c3c40803668a54bd6d897dea15 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 23:55:59 +0200 Subject: [PATCH 48/73] fix(dock): dock a panel along the edge of the whole workspace (fixes #1463) --- crates/ui-egui/src/dock_shared.rs | 20 +++++- crates/ui-egui/src/dock_ui.rs | 86 +++++++++++++++++++---- crates/ui-egui/tests/shared_docking_ui.rs | 46 ++++++++++++ docs/control-protocol.md | 2 +- 4 files changed, 140 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/dock_shared.rs b/crates/ui-egui/src/dock_shared.rs index d5bb28c2..2b64351d 100644 --- a/crates/ui-egui/src/dock_shared.rs +++ b/crates/ui-egui/src/dock_shared.rs @@ -1,6 +1,6 @@ //! Convert the persisted workspace schema to shared docking transactions. -use craft_ui::docking::{Action, Floating as SharedFloating, Layout as SharedLayout, Node}; +use craft_ui::docking::{Action, Floating as SharedFloating, Layout as SharedLayout, Location, Node, Placement, Zone}; use craft_ui::layout::{SplitAxis, SplitSize as SharedSplitSize}; use crate::dock::{DockNode, Floating, Layout, PanelKind, SplitSize}; @@ -96,6 +96,24 @@ pub(crate) fn apply_tree(root: &mut DockNode, action: Action) -> bool true } +/// Dock a panel along one edge of the whole workspace. The shared model restores a panel beside a +/// complete sibling subtree, so naming every remaining docked panel targets the root itself. +pub(crate) fn dock_at_edge(layout: &mut SharedLayout, panel: PanelKind, zone: Zone) -> Result<(), String> { + let size = match zone { + Zone::Left | Zone::Top => SharedSplitSize::Ratio(0.25), + Zone::Right | Zone::Bottom => SharedSplitSize::Ratio(0.75), + Zone::Center => return Err("a workspace edge needs a left, right, top or bottom zone".into()), + }; + let mut proposed = layout.clone(); + proposed.apply(Action::Close { panel }).map_err(|error| error.to_string())?; + let sibling: Vec = SharedLayout { root: proposed.root.clone(), floating: vec![] }.panels().into_iter().copied().collect(); + let anchor = *sibling.first().ok_or("another docked panel is required to dock beside")?; + let location = Location { floating: None, anchor: Some(anchor), placement: Placement::Split(zone), size: Some(size), stack: None, sibling }; + proposed.restore(panel, &location).map_err(|error| error.to_string())?; + *layout = proposed; + Ok(()) +} + /// 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 { diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index 8d206d71..26b30601 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -48,7 +48,6 @@ pub(crate) fn route(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, param 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")?, @@ -61,6 +60,14 @@ pub(crate) fn route(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, param "center" | "tab" => Zone::Center, _ => return Err("unknown docking zone".into()), }; + let Some(anchor) = params.get("anchor") else { + if params.get("before").is_some() { + return Err("before needs an anchor panel".into()); + } + app.dock_to_edge(panel, zone)?; + return Ok(json!({"panel": panel.id()})); + }; + let anchor = anchor.as_str().and_then(PanelKind::from_name).ok_or("a known anchor panel is required")?; 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), @@ -90,14 +97,10 @@ impl FilmcraftApp { 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; + self.commit_dock(|shared| { + crate::dock_shared::check_last_docked(shared, &action)?; + shared.apply(action).map_err(|error| error.to_string()) + })?; if let Some(panel) = reveal { self.pending_panel_raise = Some(panel); } @@ -109,6 +112,24 @@ impl FilmcraftApp { Ok(()) } + /// Dock a panel along an edge of the whole workspace rather than beside one group. + pub fn dock_to_edge(&mut self, panel: PanelKind, zone: Zone) -> Result<(), String> { + self.commit_dock(|shared| crate::dock_shared::dock_at_edge(shared, panel, zone))?; + self.ui.keys.maximized = None; + Ok(()) + } + + fn commit_dock(&mut self, change: impl FnOnce(&mut craft_ui::docking::Layout) -> Result<(), String>) -> Result<(), String> { + let mut shared = crate::dock_shared::encode(&self.ui.dock, &self.ui.floating)?; + shared.validate().map_err(|error| error.to_string())?; + change(&mut shared)?; + 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; + 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()))); @@ -256,7 +277,7 @@ impl FilmcraftApp { } } } - self.panel_drag(&ctx, &targets); + self.panel_drag(&ctx, body, &targets); panels::panel_menu_popup(self, ui); self.cancel_value_drag(ui.ctx()); } @@ -317,7 +338,7 @@ impl FilmcraftApp { output } - fn panel_drag(&mut self, ctx: &egui::Context, targets: &[DropTarget]) { + fn panel_drag(&mut self, ctx: &egui::Context, body: Rect, 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())); @@ -327,9 +348,14 @@ impl FilmcraftApp { } 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 edge = (!force_float).then(|| workspace_edge(body, pos, targets)).flatten(); + let proposal = (!force_float && edge.is_none()).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((_, _, preview)) = edge { + painter.rect_filled(preview.shrink(2.0), t.radius, t.focus.gamma_multiply(0.2)); + painter.rect_stroke(preview.shrink(2.0), t.radius, Stroke::new(2.0, t.focus), StrokeKind::Inside); + } 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); @@ -345,6 +371,12 @@ impl FilmcraftApp { ctx.request_repaint(); if ctx.input(|input| input.pointer.primary_released()) { ctx.data_mut(|data| data.remove::(drag_id())); + if let Some((_, zone, _)) = edge { + if crate::menus::invoke(self, ctx, "window.dockPanel", json!({"panel": panel.id(), "zone": zone})).is_ok() { + self.ui.focused = panel; + } + return; + } 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)) => { @@ -361,6 +393,36 @@ impl FilmcraftApp { } } +/// Width of the band along the workspace border that docks beside the whole layout. +const EDGE_BAND: f32 = 8.0; + +/// The outermost drop zone: near the workspace border a drop splits the root, not the group +/// under the pointer. A floating group over the border (it rejects splits) keeps the drop. +fn workspace_edge(body: Rect, pos: egui::Pos2, targets: &[DropTarget]) -> Option<(Zone, &'static str, Rect)> { + if !body.is_finite() || !body.contains(pos) || targets.iter().any(|target| !target.allow_splits && target.rect.contains(pos)) { + return None; + } + let band = EDGE_BAND.min(body.width() / 4.0).min(body.height() / 4.0); + let edges = [ + (pos.x - body.left(), Zone::Left, "left"), + (body.right() - pos.x, Zone::Right, "right"), + (pos.y - body.top(), Zone::Top, "top"), + (body.bottom() - pos.y, Zone::Bottom, "bottom"), + ]; + let (distance, zone, name) = edges.into_iter().min_by(|a, b| a.0.total_cmp(&b.0))?; + if distance > band { + return None; + } + let size = body.size() * 0.25; + let preview = match zone { + Zone::Left => Rect::from_min_size(body.min, vec2(size.x, body.height())), + Zone::Right => Rect::from_min_max(pos2(body.right() - size.x, body.top()), body.max), + Zone::Top => Rect::from_min_size(body.min, vec2(body.width(), size.y)), + Zone::Bottom | Zone::Center => Rect::from_min_max(pos2(body.left(), body.bottom() - size.y), body.max), + }; + Some((zone, name, preview)) +} + 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. diff --git a/crates/ui-egui/tests/shared_docking_ui.rs b/crates/ui-egui/tests/shared_docking_ui.rs index fcabbdd0..3df87b90 100644 --- a/crates/ui-egui/tests/shared_docking_ui.rs +++ b/crates/ui-egui/tests/shared_docking_ui.rs @@ -275,6 +275,52 @@ fn real_panel_drag_merges_splits_floats_and_redocks_without_editing_documents() assert!(h.state().ui_error.is_none()); } +#[test] +fn dropping_on_the_window_edge_docks_beside_the_whole_workspace() { + let mut app = FilmcraftApp::new(Session::default()); + app.ui.dock = layout().root; + let mut h = Harness::builder().with_size(vec2(1200.0, 760.0)).build_eframe(move |_| app); + for _ in 0..4 { + step(&mut h); + } + let from = rect(&h, "panel.tab.Info").center(); + let leftmost = rect(&h, "panel.Info"); + // The outer edge of the leftmost group: without the window-edge zone this splits only that group. + drag(&mut h, from, pos2(leftmost.left() + 2.0, leftmost.center().y), Modifiers::NONE); + assert_eq!( + h.state().ui.dock, + DockNode::Split { + vertical: false, + size: SplitSize::Ratio(0.25), + a: Box::new(DockNode::Tabs { panels: vec![PanelKind::Info], active: 0 }), + b: Box::new(DockNode::Split { + vertical: false, + size: SplitSize::FixedA(400.0), + a: Box::new(DockNode::Tabs { panels: vec![PanelKind::Project, PanelKind::History], active: 1 }), + b: Box::new(DockNode::Tabs { panels: vec![PanelKind::Source, PanelKind::Program], active: 1 }), + }), + } + ); +} + +#[test] +fn dock_panel_without_anchor_docks_along_a_workspace_edge() { + let mut app = FilmcraftApp::new(Session::default()); + app.ui.dock = layout().root; + let ctx = egui::Context::default(); + menus::invoke(&mut app, &ctx, "window.dockPanel", json!({"panel": "Program", "zone": "bottom"})).unwrap(); + let DockNode::Split { vertical: true, size: SplitSize::Ratio(ratio), a, b } = &app.ui.dock else { panic!("root not split: {:?}", app.ui.dock) }; + assert_eq!(*ratio, 0.75); + assert_eq!(**b, DockNode::Tabs { panels: vec![PanelKind::Program], active: 0 }); + assert!(!a.contains(PanelKind::Program) && a.contains(PanelKind::Source) && a.contains(PanelKind::Info)); + let before = app.ui.dock.clone(); + assert!(menus::invoke(&mut app, &ctx, "window.dockPanel", json!({"panel": "Info", "zone": "center"})).is_err()); + assert_eq!(app.ui.dock, before); + app.ui.dock = DockNode::Tabs { panels: vec![PanelKind::Project], active: 0 }; + assert!(menus::invoke(&mut app, &ctx, "window.dockPanel", json!({"panel": "Project", "zone": "left"})).is_err()); + assert_eq!(app.ui.dock, DockNode::Tabs { panels: vec![PanelKind::Project], active: 0 }); +} + fn same_group(root: &DockNode, first: PanelKind, second: PanelKind) -> bool { match root { DockNode::Tabs { panels, .. } => panels.contains(&first) && panels.contains(&second), diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 4196a42e..80802cf2 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -177,7 +177,7 @@ Time is in ticks (254 016 000 000 per second); commands also accept `seconds`, ` | 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.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. Without `anchor`, a side `zone` docks the panel along that edge of the whole workspace, at a quarter of its width or height. | | `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 From 59506d9b4aebb60c2f0ecfd874364b3ae61ae370 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 23:59:49 +0200 Subject: [PATCH 49/73] =?UTF-8?q?fix(home):=20list=20recent=20projects=20o?= =?UTF-8?q?n=20Home=20and=20in=20File=20=E2=96=B8=20Open=20Recent=20(fixes?= =?UTF-8?q?=20#1461)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 3 ++ crates/ui-egui/src/i18n/fr.tsv | 3 ++ crates/ui-egui/src/i18n/id.tsv | 3 ++ crates/ui-egui/src/i18n/ja.tsv | 3 ++ crates/ui-egui/src/i18n/pt-br.tsv | 3 ++ crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/zh-cn.tsv | 3 ++ crates/ui-egui/src/menus.rs | 34 +++++++++++++++++ crates/ui-egui/src/panels/import_mode.rs | 47 +++++++++++++++++++++++- crates/ui-egui/tests/menus_ui.rs | 38 +++++++++++++++++++ 12 files changed, 141 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 21c0be07..25ec27b9 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -248,6 +248,8 @@ OMF… OMF… Offline File… Offline-Datei… Open Project… Projekt öffnen… + Open Recent Zuletzt geöffnete Dateien + Clear Recent Files Zuletzt geöffnete Dateien leeren OpenTimelineIO… OpenTimelineIO… Optical Flow Optischer Fluss Overwrite Überschreiben diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 25a23480..c67b68f6 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -230,6 +230,9 @@ Normalize Mix Track… Normalizar pista de mezcla… Offline File… Archivo sin conexión… Open Project… Abrir proyecto… + Open Recent Archivos recientes + Clear Recent Files Vaciar archivos recientes + Recent Recientes Optical Flow Flujo óptico Overwrite Sobrescribir Paste Pegar diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 4385c0dd..dd975670 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -232,6 +232,9 @@ Normalize Mix Track… Normaliser la piste de mixage… Offline File… Fichier hors ligne… Open Project… Ouvrir un projet… + Open Recent Ouvrir un fichier récent + Clear Recent Files Effacer la liste des fichiers récents + Recent Récents Optical Flow Flux optique Overwrite Écraser Paste Coller diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 1c44965f..19d2eb60 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -237,6 +237,9 @@ Normalize Mix Track… Normalisasi Trek Mix… Offline File… Berkas Offline… Open Project… Buka Proyek… + Open Recent Terakhir Dibuka + Clear Recent Files Hapus Daftar Berkas Terakhir + Recent Terbaru Optical Flow Aliran Optik Overwrite Timpa Paste Tempel diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 13673e6c..4d2431c2 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -231,6 +231,9 @@ Normalize Mix Track… ミックストラックをノーマライズ… Offline File… オフラインファイル… Open Project… プロジェクトを開く… + Open Recent 最近使ったファイルを開く + Clear Recent Files 最近使ったファイルを消去 + Recent 最近 Optical Flow オプティカルフロー Overwrite 上書き Paste 貼り付け diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 17498760..9ace0c51 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -234,6 +234,9 @@ Normalize Mix Track… Normalizar faixa de mixagem… Offline File… Arquivo offline… Open Project… Abrir projeto… + Open Recent Abrir recentes + Clear Recent Files Limpar arquivos recentes + Recent Recentes Optical Flow Fluxo óptico Overwrite Sobrescrever Paste Colar diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 747ffde2..8ffaf83a 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -221,6 +221,8 @@ Normalize Mix Track… Нормализовать мастер-дорожку… Offline File… Автономный файл… Open Project… Открыть проект… + Open Recent Открыть недавние + Clear Recent Files Очистить список недавних файлов Optical Flow Оптический поток Overwrite Перезаписать Paste Вставить diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 3ce73588..f8bbe117 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -222,6 +222,8 @@ Normalize Mix Track… Нормалізувати доріжку міксу… Offline File… Автономний файл… Open Project… Відкрити проєкт… + Open Recent Нещодавні файли + Clear Recent Files Очистити список нещодавніх файлів Optical Flow Оптичний потік Overwrite Перезаписати Paste Вставити diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index 7459caa1..5756871b 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -225,6 +225,9 @@ Normalize Mix Track… 标准化混音轨道… Offline File… 脱机文件… Open Project… 打开项目… + Open Recent 打开最近文件 + Clear Recent Files 清除最近文件 + Recent 最近使用 Optical Flow 光流 Overwrite 覆盖 Paste 粘贴 diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 04d79ac3..191b924c 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -550,6 +550,17 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: crate::panels::settings::set_theme(app, ctx, k); return Ok(Value::Null); } + // File ▸ Open Recent + if let Some(i) = id.strip_prefix("file.openRecent.") { + let path = i.parse::().ok().and_then(|i| app.session.prefs.general.recent_projects.get(i).cloned()).ok_or("no such recent project")?; + return invoke(app, ctx, "file.open", json!({"path": path})); + } + if id == "file.clearRecent" { + let mut next = app.session.prefs.clone(); + next.general.recent_projects.clear(); + app.session.set_prefs(next).map_err(|e| e.to_string())?; + return Ok(Value::Null); + } // File dialogs for commands that need a path. if (id == "file.import" && params.get("paths").is_none() && params.get("path").is_none()) || (id == "file.importImageSequence" && params.get("path").is_none()) @@ -682,9 +693,31 @@ pub fn menu_items(app: &FilmcraftApp) -> Vec { } } crate::panels::workspaces::menu(app, &mut v); + open_recent(&mut v, &app.session, |n| app.ui.language.tr(n).to_string()); v } +/// File ▸ Open Recent, after Open Project…: the recent projects (General ▸ `recentProjects`), +/// newest first, then Clear Recent Files. Clear is always there, so the submenu exists (in the +/// native menu too, built once at launch) before the first project is saved. +fn open_recent(items: &mut Vec, session: &filmcraft_engine::Session, tr: impl Fn(&str) -> String) { + let Some(at) = items.iter().position(|it| it.id == "file.open") else { return }; + let path: Vec = vec!["File".into(), "Open Recent".into()]; + let recent = &session.prefs.general.recent_projects; + let mut sub: Vec = recent + .iter() + .enumerate() + .map(|(i, p)| MenuItem { id: format!("file.openRecent.{i}"), label: recent_name(p), path: path.clone(), shortcut: None, enabled: true, checked: None }) + .collect(); + sub.push(MenuItem { id: "file.clearRecent".into(), label: tr("Clear Recent Files"), path, shortcut: None, enabled: !recent.is_empty(), checked: None }); + items.splice(at + 1..at + 1, sub); +} + +/// A recent project as listed in File ▸ Open Recent and on Home: its file name. +pub fn recent_name(path: &str) -> String { + std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().into_owned()) +} + /// 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). @@ -692,6 +725,7 @@ 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); + open_recent(&mut v, &session, str::to_string); v } diff --git a/crates/ui-egui/src/panels/import_mode.rs b/crates/ui-egui/src/panels/import_mode.rs index ef68c0aa..2af12cad 100644 --- a/crates/ui-egui/src/panels/import_mode.rs +++ b/crates/ui-egui/src/panels/import_mode.rs @@ -1,4 +1,5 @@ -//! Import mode (header "Import"): large browser cards for importing media and demo footage. +//! Import mode (header "Import", also Home): large browser cards for importing media and demo footage, +//! and the recent projects. use egui::{Align2, Color32, Rect, Sense, pos2, vec2}; use serde_json::json; @@ -43,8 +44,8 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { app.ui.mode = crate::state::Mode::Edit; } } + let y = recent(app, ui, rect, 90.0 + 150.0 + 36.0); // Community and project links. - let y = 90.0 + 150.0 + 36.0; // ArtCraft wordmark (first-party trademark, docs/brand/), then the section title. let logo = crate::brand::paint_wordmark(ui, rect.min + vec2(24.0, y), 15.0, app.ui.dark); let tx = logo.map_or(rect.min.x + 24.0, |r| r.max.x + 12.0); @@ -72,3 +73,45 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { x = r.max.x + 12.0; } } + +/// Recent projects listed on Home, as in File ▸ Open Recent. +const RECENT_ROWS: usize = 6; +const RECENT_ROW_H: f32 = 28.0; + +/// Home's Recent list at `y`: the recent projects (newest first, file name with its folder beside +/// it), a click opens one. Returns where the next section starts. +fn recent(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, y: f32) -> f32 { + let paths: Vec = app.session.prefs.general.recent_projects.iter().take(RECENT_ROWS).cloned().collect(); + if paths.is_empty() { + return y; + } + let t = app.tokens; + ui.painter().text(rect.min + vec2(24.0, y), Align2::LEFT_CENTER, tl!("Recent"), Tokens::semibold(15.0), t.text); + let mut open = None; + for (i, path) in paths.iter().enumerate() { + let row = + Rect::from_min_size(rect.min + vec2(16.0, y + 16.0 + i as f32 * RECENT_ROW_H), vec2(560.0_f32.min(rect.width() - 32.0).max(0.0), RECENT_ROW_H)); + let name = crate::menus::recent_name(path); + let resp = ui.interact(row, egui::Id::new(("imp-recent", i)), Sense::click()).on_hover_text(path.as_str()); + app.auto.add(&format!("import.recent.{i}"), row, &name); + if resp.hovered() { + ui.painter().rect_filled(row, 4.0, t.hover); + } + let p = ui.painter().with_clip_rect(row); + crate::icons::paint(&p, Rect::from_center_size(pos2(row.min.x + 16.0, row.center().y), vec2(14.0, 14.0)), crate::icons::Icon::Sequence, t.text_dim); + let name_r = p.text(pos2(row.min.x + 32.0, row.center().y), Align2::LEFT_CENTER, &name, Tokens::ui(13.0), t.text); + if let Some(folder) = std::path::Path::new(path).parent() { + p.text(pos2(name_r.max.x + 12.0, row.center().y), Align2::LEFT_CENTER, folder.to_string_lossy(), Tokens::ui(11.5), t.text_dim); + } + if resp.clicked() { + open = Some(path.clone()); + } + } + if let Some(path) = open { + let ctx = ui.ctx().clone(); + if crate::menus::invoke(app, &ctx, "file.open", json!({"path": path})).is_ok() { + app.ui.mode = crate::state::Mode::Edit; + } + } + y + 16.0 + paths.len() as f32 * RECENT_ROW_H + 36.0 +} diff --git a/crates/ui-egui/tests/menus_ui.rs b/crates/ui-egui/tests/menus_ui.rs index b3354b2d..0cdc81c3 100644 --- a/crates/ui-egui/tests/menus_ui.rs +++ b/crates/ui-egui/tests/menus_ui.rs @@ -322,3 +322,41 @@ fn reveal_in_finder_opens_an_imported_clip() { 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); } + +/// A project saved for the first time is listed at the top of Home's Recent list and of File ▸ +/// Open Recent, both of which reopen it; Clear Recent Files empties the list (#1461). +#[test] +fn saved_projects_are_listed_in_home_and_open_recent() { + let dir = std::env::temp_dir().join(format!("fc-ui-recent-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let a = dir.join("first cut.fcproj").to_string_lossy().to_string(); + let b = dir.join("second cut.fcproj").to_string_lossy().to_string(); + let mut d = Driver::new(); + d.exec("file.saveAs", json!({"path": a})); + d.exec("file.newProject", json!({"name": "Second"})); + d.exec("file.saveAs", json!({"path": b})); + let menus = d.ok("ui.menu.list", json!({})); + let recent: Vec<&Value> = menus.as_array().unwrap().iter().filter(|m| m["path"] == json!(["File", "Open Recent"])).collect(); + let labels: Vec<&str> = recent.iter().map(|m| m["label"].as_str().unwrap()).collect(); + assert_eq!(labels, ["second cut.fcproj", "first cut.fcproj", "Clear Recent Files"]); + assert_eq!(recent[0]["id"], "file.openRecent.0"); + // File ▸ Open Recent reopens the older one, which moves to the top + d.menu("file.openRecent.1"); + assert_eq!(d.app().session.path.as_deref(), Some(a.as_str())); + assert_eq!(d.app().session.prefs.general.recent_projects, [a.clone(), b.clone()]); + // Home lists them too, newest first, and a click opens one + d.click("header.home"); + let rows = d.ok("ui.elements", json!({"prefix": "import.recent."})); + let rows: Vec<&str> = rows.as_array().unwrap().iter().map(|e| e["label"].as_str().unwrap()).collect(); + assert_eq!(rows, ["first cut.fcproj", "second cut.fcproj"]); + d.click("import.recent.1"); + assert_eq!(d.app().session.path.as_deref(), Some(b.as_str())); + // Clear Recent Files empties both lists + d.menu("file.clearRecent"); + assert!(d.app().session.prefs.general.recent_projects.is_empty()); + let menus = d.ok("ui.menu.list", json!({})); + let clear = menus.as_array().unwrap().iter().find(|m| m["id"] == "file.clearRecent").cloned().expect("Clear Recent Files"); + assert_eq!(clear["enabled"], false); + assert!(!menus.as_array().unwrap().iter().any(|m| m["id"].as_str().is_some_and(|id| id.starts_with("file.openRecent.")))); + let _ = std::fs::remove_dir_all(&dir); +} From 55c583f62bd2474c6530259d6a608a45e4a92f70 Mon Sep 17 00:00:00 2001 From: txiki739 <111882715+txiki739@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:08:20 +0200 Subject: [PATCH 50/73] #262: Linux file dialogs shown off the UI thread keep case-insensitive filters #260 moved Linux file dialogs to a worker thread (run_dialog_off_thread), which built its rfd filters from the plain extension lists. The case-insensitive patterns from file_filters::extensions (#262, #390) were only applied in the synchronous pickers, so on Linux every dialog hid CLIP.MP4, clip.Mp4 and EDIT.FCPROJ again: import, relink, open file, open/save project and save as. prepare_dialog now turns every extension list into those patterns (and translates the filter names, as before); the project dialogs use project_patterns(). The literal extension stays first for save suffixes. Co-Authored-By: Claude Opus 5.5 --- apps/filmcraft/src/main.rs | 61 +++++++++++++++++++++++++++++++++----- 1 file changed, 54 insertions(+), 7 deletions(-) diff --git a/apps/filmcraft/src/main.rs b/apps/filmcraft/src/main.rs index 3bb009ea..f850b163 100644 --- a/apps/filmcraft/src/main.rs +++ b/apps/filmcraft/src/main.rs @@ -447,11 +447,8 @@ fn run_dialog_off_thread(dialog: filmcraft_ui_egui::native_dialogs::FileDialog) let t = |s: &str| filmcraft_ui_egui::i18n::t(s).to_string(); let media = t("Media"); let project = t("FilmCraft Project"); - let dialog = match dialog { - FileDialog::OpenFile { filter, exts } => FileDialog::OpenFile { filter: t(&filter), exts }, - FileDialog::SaveAs { filter, exts, name } => FileDialog::SaveAs { filter: t(&filter), exts, name }, - other => other, - }; + let project_exts = project_patterns(); + let dialog = prepare_dialog(dialog); let (tx, rx) = std::sync::mpsc::channel(); let spawned = std::thread::Builder::new().name("file-dialog".into()).spawn(move || { let shown = std::panic::catch_unwind(move || { @@ -462,9 +459,9 @@ fn run_dialog_off_thread(dialog: filmcraft_ui_egui::native_dialogs::FileDialog) } FileDialog::OpenFile { filter, exts } => rfd::FileDialog::new().add_filter(&filter, &exts).pick_file().map(path).into_iter().collect(), FileDialog::Relink { exts, .. } => rfd::FileDialog::new().add_filter(&media, &exts).pick_file().map(path).into_iter().collect(), - FileDialog::OpenProject => rfd::FileDialog::new().add_filter(&project, &["fcproj"]).pick_file().map(path).into_iter().collect(), + FileDialog::OpenProject => rfd::FileDialog::new().add_filter(&project, &project_exts).pick_file().map(path).into_iter().collect(), FileDialog::SaveProject { name } => { - rfd::FileDialog::new().add_filter(&project, &["fcproj"]).set_file_name(name).save_file().map(path).into_iter().collect() + rfd::FileDialog::new().add_filter(&project, &project_exts).set_file_name(name).save_file().map(path).into_iter().collect() } FileDialog::SaveAs { filter, exts, name } => { rfd::FileDialog::new().add_filter(&filter, &exts).set_file_name(name).save_file().map(path).into_iter().collect() @@ -492,6 +489,29 @@ fn run_dialog_off_thread(dialog: filmcraft_ui_egui::native_dialogs::FileDialog) rx } +/// A dialog as the Linux dialog thread shows it: filter names translated (here, on the UI thread: +/// the interface language is per thread) and every extension list turned into the +/// case-insensitive patterns the synchronous pickers use, so `CLIP.MP4` stays visible (#262). +#[cfg(target_os = "linux")] +fn prepare_dialog(dialog: filmcraft_ui_egui::native_dialogs::FileDialog) -> filmcraft_ui_egui::native_dialogs::FileDialog { + use filmcraft_ui_egui::native_dialogs::FileDialog; + let t = |s: &str| filmcraft_ui_egui::i18n::t(s).to_string(); + let patterns = |exts: Vec| file_filters::extensions(&exts.iter().map(String::as_str).collect::>()); + match dialog { + FileDialog::OpenFiles { exts } => FileDialog::OpenFiles { exts: patterns(exts) }, + FileDialog::OpenFile { filter, exts } => FileDialog::OpenFile { filter: t(&filter), exts: patterns(exts) }, + FileDialog::SaveAs { filter, exts, name } => FileDialog::SaveAs { filter: t(&filter), exts: patterns(exts), name }, + FileDialog::Relink { exts, hint } => FileDialog::Relink { exts: patterns(exts), hint }, + other => other, + } +} + +/// The project dialogs' filter patterns (`EDIT.FCPROJ` included). +#[cfg(target_os = "linux")] +fn project_patterns() -> Vec { + file_filters::extensions(&["fcproj"]) +} + #[cfg(test)] mod tests { /// Start-up registers the hardware decoders whether or not anything is logged (no logger is @@ -505,4 +525,31 @@ mod tests { || (cfg!(target_os = "linux") && matches!(hardware, filmcraft_platform::Availability::Available(_))); assert_eq!(filmcraft_platform::registered(), expected, "{hardware:?}"); } + + /// #262 on Linux: the dialogs shown off the UI thread (#260) filter with the same + /// case-insensitive patterns as the synchronous pickers, so upper- and mixed-case files show. + #[cfg(target_os = "linux")] + #[test] + fn off_thread_dialogs_keep_case_insensitive_filters() { + use filmcraft_ui_egui::RelinkHint; + use filmcraft_ui_egui::native_dialogs::FileDialog; + let mp4 = || vec!["mp4".to_string()]; + let hint = RelinkHint { command: "media.relink".into(), params: serde_json::json!({}) }; + for dialog in [ + FileDialog::OpenFiles { exts: mp4() }, + FileDialog::OpenFile { filter: "Media".into(), exts: mp4() }, + FileDialog::SaveAs { filter: "Media".into(), exts: mp4(), name: "clip.mp4".into() }, + FileDialog::Relink { exts: mp4(), hint }, + ] { + let exts = match super::prepare_dialog(dialog) { + FileDialog::OpenFiles { exts } | FileDialog::OpenFile { exts, .. } | FileDialog::SaveAs { exts, .. } | FileDialog::Relink { exts, .. } => exts, + other => panic!("the dialog changed kind: {other:?}"), + }; + assert_eq!(exts.first().map(String::as_str), Some("mp4"), "the literal extension stays first (save suffix)"); + assert!(exts.iter().any(|e| e == "[mM][pP]4"), "{exts:?}"); + } + let project = super::project_patterns(); + assert_eq!(project.first().map(String::as_str), Some("fcproj")); + assert!(project.iter().any(|e| e == "[fF][cC][pP][rR][oO][jJ]"), "{project:?}"); + } } From d91a7b3122913167d1050a2fd4bf987a5388bf27 Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:11:35 +1000 Subject: [PATCH 51/73] ui: keep Metadata and Info on the selected source item Co-authored-by: Codex --- crates/ui-egui/src/dock.rs | 10 +- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/panels/metadata.rs | 29 ++-- crates/ui-egui/src/panels/misc.rs | 8 +- crates/ui-egui/src/panels/panel_state.rs | 12 ++ crates/ui-egui/tests/panels_ui.rs | 173 +++++++++++++++++++++++ 6 files changed, 220 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 48d29b55..d144bd57 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -860,12 +860,18 @@ pub fn draw_group_chrome_details(ui: &mut egui::Ui, g: &Group, seqs: &SeqTabs, t ui.ctx().data_mut(|d| d.remove::(list_id)); } } - // clicking anywhere in the panel focuses it + // Activating presses focus their tab immediately, rather than the group's old active panel. + // Otherwise entering an inspector can briefly reactivate a stale Project/Timeline selection. + // Middle-click closes a tab, so it keeps the active panel as the focus target. if let Some(p) = active_panel && ui.rect_contains_pointer(g.rect) && ui.input(|i| i.pointer.any_pressed()) { - actions.push(DockAction::Focus(p)); + let panel = ui + .input(|i| i.pointer.interact_pos().filter(|_| i.pointer.primary_pressed() || i.pointer.secondary_pressed())) + .and_then(|pos| tab_rects.iter().find(|(_, rect)| rect.contains(pos)).map(|(panel, _)| *panel)) + .unwrap_or(p); + actions.push(DockAction::Focus(panel)); } Chrome { actions, tabs: tab_rects } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9a6393b8..6b5cc488 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1693,6 +1693,7 @@ impl FilmcraftApp { } let ctx = ui.ctx().clone(); self.auto.begin_frame(); + panels::metadata::follow_selection(self); self.frames.set_context(&ctx); self.session.poll_persistence(); panels::trim_monitor::advance(self, &ctx); diff --git a/crates/ui-egui/src/panels/metadata.rs b/crates/ui-egui/src/panels/metadata.rs index c8c7f558..88312f1a 100644 --- a/crates/ui-egui/src/panels/metadata.rs +++ b/crates/ui-egui/src/panels/metadata.rs @@ -1,5 +1,5 @@ //! Metadata panel: the clip and file properties of the selected item (Project panel selection, or -//! the clip selected in the Timeline when it has focus). Name, Label and the log fields +//! the clip selected in the Timeline, retained while an inspector has focus). Name, Label and the log fields //! (Description, Scene, Shot, Log Note, Comment, Tape Name, Client, Camera Angle) are editable; //! each committed edit is one `metadata.set` (undoable, saved in the project). //! @@ -7,7 +7,7 @@ //! `metadata.label`, `metadata.section.`. use egui::{Rect, RichText, Sense, vec2}; -use filmcraft_engine::panels::{Field, metadata_fields, metadata_target}; +use filmcraft_engine::panels::{Field, metadata_fields}; use filmcraft_project::{ItemId, Label}; use serde_json::json; @@ -15,15 +15,28 @@ use crate::FilmcraftApp; use crate::dock::PanelKind; use crate::theme::Tokens; -/// The item the panel shows. +use super::panel_state::InspectorSource; + +/// Remember the selection source even when both item inspectors are hidden. +pub(crate) fn follow_selection(app: &mut FilmcraftApp) { + match app.ui.focused { + PanelKind::Project => app.ui.panels.inspector_source = InspectorSource::Project, + PanelKind::Timeline => app.ui.panels.inspector_source = InspectorSource::Timeline, + _ => {} + } +} + +/// The item Metadata and Info show. Keep the preferred source while entering either inspector; +/// an empty selection falls back to the other panel, as Metadata did before source retention. pub fn target(app: &FilmcraftApp) -> Option { let s = &app.session; - if app.ui.focused == PanelKind::Timeline - && let Some(c) = s.state.selection.first() - { - return metadata_target(s, &json!({"clip": c.0})); + let exists = |id: &ItemId| s.project.item(*id).is_some(); + let project = s.state.project_selection.first().copied().filter(exists); + let timeline = s.state.selection.first().and_then(|clip| s.active_sequence()?.find_item(*clip).map(|(_, item)| item.item)).filter(exists); + match app.ui.panels.inspector_source { + InspectorSource::Project => project.or(timeline), + InspectorSource::Timeline => timeline.or(project), } - metadata_target(s, &json!({})) } pub fn field_id(name: &str) -> String { diff --git a/crates/ui-egui/src/panels/misc.rs b/crates/ui-egui/src/panels/misc.rs index 744e9cfb..f5bc5f1e 100644 --- a/crates/ui-egui/src/panels/misc.rs +++ b/crates/ui-egui/src/panels/misc.rs @@ -167,8 +167,9 @@ pub fn info(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.label(v); }); }; - if let Some(item) = app.session.state.project_selection.first().and_then(|i| app.session.project.item(*i)).cloned() { - ui.label(egui::RichText::new(&item.name).strong()); + if let Some(item) = super::metadata::target(app).and_then(|id| app.session.project.item(id)).cloned() { + let header = ui.label(egui::RichText::new(&item.name).strong()); + app.auto.add_response("info.item", &header, &item.name); line(ui, tl!("Type:"), crate::i18n::t(item.type_label()).to_string()); if let Some(m) = item.as_media() { if let Some(v) = &m.info.video { @@ -184,7 +185,8 @@ pub fn info(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } if let Some(seq) = app.session.active_sequence() { let name = app.session.state.active_sequence.and_then(|s| app.session.project.item(s)).map(|i| i.name.clone()).unwrap_or_default(); - ui.label(egui::RichText::new(name).strong()); + let header = ui.label(egui::RichText::new(&name).strong()); + app.auto.add_response("info.sequence", &header, &name); line( ui, tl!("Settings:"), diff --git a/crates/ui-egui/src/panels/panel_state.rs b/crates/ui-egui/src/panels/panel_state.rs index 20f47e39..74f56ccd 100644 --- a/crates/ui-egui/src/panels/panel_state.rs +++ b/crates/ui-egui/src/panels/panel_state.rs @@ -14,6 +14,18 @@ pub struct PanelsState { pub reference: ReferenceState, pub events: EventsState, pub progress: ProgressState, + /// Preferred selection for Metadata and Info; inspector focus does not replace it. + pub inspector_source: InspectorSource, +} + +/// The last selection panel used by the item inspectors. Only the source is retained, so +/// project changes, deletion and deselection always resolve against the current selection. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum InspectorSource { + #[default] + Project, + Timeline, } /// Lumetri Scopes settings (the wrench / right-click menu and the footer). diff --git a/crates/ui-egui/tests/panels_ui.rs b/crates/ui-egui/tests/panels_ui.rs index 504f4f3e..acc672a3 100644 --- a/crates/ui-egui/tests/panels_ui.rs +++ b/crates/ui-egui/tests/panels_ui.rs @@ -10,6 +10,7 @@ use std::sync::Arc; use std::sync::mpsc::{Sender, channel}; use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; use filmcraft_engine::Session; use filmcraft_ui_egui::FilmcraftApp; use filmcraft_ui_egui::control::ControlRequest; @@ -571,3 +572,175 @@ fn focused_panel_outline_is_not_covered_by_child_backgrounds() { } } } + +/// Select two different sources using the same Project-row and Timeline-clip clicks as an editor. +fn inspector_selection_driver() -> (Driver, filmcraft_project::ItemId, filmcraft_project::ItemId, filmcraft_project::ClipId) { + let mut d = Driver::demo(); + let seq = d.app().session.active_sequence().unwrap(); + let a = seq.video_tracks[0].items[0].item; + let clip = seq.video_tracks[0].items.iter().find(|clip| clip.item != a).unwrap(); + let (b, clip) = (clip.item, clip.id); + let bin = d + .app() + .session + .project + .root + .children + .iter() + .find_map(|entry| match entry { + filmcraft_project::BinEntry::Bin(bin) if bin.name == "Footage" => Some(bin.id.0), + _ => None, + }) + .unwrap(); + d.exec("project.view.set", json!({"view":"list"})); + d.ok("ui.menu.invoke", json!({"id":"window.panel.Metadata"})); + d.ok("ui.menu.invoke", json!({"id":"window.panel.Info"})); + d.click("panel.tab.Project"); + d.ok("ui.menu.invoke", json!({"id":"projectPanel.openBin","params":{"bin":bin,"how":"inPlace"}})); + d.click(&format!("project.item.{}", a.0)); + assert_eq!(d.app().session.state.project_selection, vec![a]); + d.click(&format!("timeline.clip.{}", clip.0)); + assert!(d.app().session.state.selection.contains(&clip)); + (d, a, b, clip) +} + +#[test] +fn metadata_keeps_the_timeline_item_when_its_tab_and_field_take_focus() { + let (mut d, a, b, _) = inspector_selection_driver(); + let b_name = d.app().session.project.item(b).unwrap().name.clone(); + assert_eq!(d.label("metadata.header"), format!("Clip: {b_name}")); + d.click("panel.tab.Metadata"); + assert_eq!(d.label("metadata.header"), format!("Clip: {b_name}"), "inspector focus must not reactivate the old Project selection"); + let before = serde_json::to_value(&d.app().session.project).unwrap(); + let undo = d.app().session.history.undo.len(); + d.click("metadata.field.Scene"); + d.ok("ui.type", json!({"text":"42B"})); + d.ok("ui.key", json!({"key":"Enter"})); + d.frames(3); + assert_eq!(d.app().session.project.item(b).unwrap().metadata.get("Scene").map(String::as_str), Some("42B")); + assert!(!d.app().session.project.item(a).unwrap().metadata.contains_key("Scene")); + assert_eq!(d.app().session.history.undo.len(), undo + 1); + let after = serde_json::to_value(&d.app().session.project).unwrap(); + d.exec("edit.undo", json!({})); + assert_eq!(serde_json::to_value(&d.app().session.project).unwrap(), before); + d.exec("edit.redo", json!({})); + assert_eq!(serde_json::to_value(&d.app().session.project).unwrap(), after); + d.snapshot("panels-metadata-timeline-selection", Some("metadata.")); +} + +#[test] +fn info_follows_the_timeline_item_and_keeps_sequence_information() { + let (mut d, _, b, _) = inspector_selection_driver(); + let name = d.app().session.project.item(b).unwrap().name.clone(); + d.click("panel.tab.Info"); + assert!(d.harness.query_by_label(&name).is_some(), "Info must show the selected Timeline item's name"); + assert_eq!(d.label("info.item"), name, "the item header belongs to Info, not another panel"); + assert!(!d.label("info.sequence").is_empty()); + assert!(d.harness.query_by_label("Settings:").is_some(), "Info keeps its lower sequence-information section"); + d.snapshot("panels-info-timeline-selection", None); +} + +#[test] +fn inspectors_follow_reselected_project_items_and_clear_without_stale_targets() { + let (mut d, a, b, clip) = inspector_selection_driver(); + let a_name = d.app().session.project.item(a).unwrap().name.clone(); + let b_name = d.app().session.project.item(b).unwrap().name.clone(); + let timeline_selection = d.app().session.state.selection.clone(); + let undo = d.app().session.history.undo.len(); + let playhead = d.app().session.playhead(); + d.click("panel.tab.Info"); + assert_eq!(d.label("info.item"), b_name); + // Selection ownership also follows actual source clicks while both inspectors are hidden. + d.ok("ui.menu.invoke", json!({"id":"window.panel.Source"})); + d.click("panel.tab.Project"); + d.click(&format!("project.item.{}", a.0)); + d.click(&format!("timeline.clip.{}", clip.0)); + d.ok("ui.menu.invoke", json!({"id":"window.panel.Metadata"})); + d.frames(3); + assert_eq!(d.label("metadata.header"), format!("Clip: {b_name}")); + d.click("panel.tab.Project"); + d.click(&format!("project.item.{}", a.0)); + d.click("panel.tab.Metadata"); + assert_eq!(d.label("metadata.header"), format!("Clip: {a_name}")); + d.click("panel.tab.Info"); + assert_eq!(d.label("info.item"), a_name); + assert_eq!(d.app().session.state.selection, timeline_selection); + assert_eq!(d.app().session.state.project_selection, vec![a]); + assert_eq!(d.app().session.history.undo.len(), undo, "inspector focus does not create edits"); + assert_eq!(d.app().session.playhead(), playhead); + + // Preserve Metadata's existing fallback in both directions when a selection is cleared. + d.click(&format!("timeline.clip.{}", clip.0)); + d.ok("ui.key", json!({"key":"Cmd+Shift+A"})); + assert!(d.app().session.state.selection.is_empty()); + d.click("panel.tab.Metadata"); + assert_eq!(d.label("metadata.header"), format!("Clip: {a_name}")); + d.click("panel.tab.Info"); + assert_eq!(d.label("info.item"), a_name); + d.click(&format!("timeline.clip.{}", clip.0)); + d.click("panel.tab.Project"); + d.ok("ui.key", json!({"key":"Cmd+Shift+A"})); + assert!(d.app().session.state.project_selection.is_empty()); + d.click("panel.tab.Info"); + assert_eq!(d.label("info.item"), b_name); + d.click("panel.tab.Metadata"); + assert_eq!(d.label("metadata.header"), format!("Clip: {b_name}")); + d.ok("ui.set", json!({"theme":"light"})); + d.click("panel.tab.Info"); + assert_eq!(d.label("info.item"), b_name, "theme and inspector changes keep the selection source"); + d.snapshot("panels-info-timeline-selection-light", Some("info.")); + + d.click(&format!("timeline.clip.{}", clip.0)); + d.ok("ui.key", json!({"key":"Cmd+Shift+A"})); + assert!(d.app().session.state.selection.is_empty()); + d.click("panel.tab.Metadata"); + assert!(d.has("metadata.empty")); + d.click("panel.tab.Info"); + assert!(!d.has("info.item")); + assert!(!d.label("info.sequence").is_empty(), "active sequence details remain with no clip selected"); + + d.click("panel.tab.Project"); + d.click(&format!("project.item.{}", a.0)); + d.click("panel.tab.Info"); + assert_eq!(d.label("info.item"), a_name); + d.exec("file.newProject", json!({"name":"Fresh inspector project"})); + d.frames(3); + assert!(!d.has("info.item"), "no item identity is retained from the old project"); + assert!(!d.has("info.sequence")); + d.click("panel.tab.Metadata"); + assert!(d.has("metadata.empty")); +} + +#[test] +fn inspector_source_defaults_when_loading_an_older_ui_state() { + let ui = filmcraft_ui_egui::state::UiState::default(); + let mut saved = serde_json::to_value(&ui).unwrap(); + saved["panels"].as_object_mut().unwrap().remove("inspectorSource"); + let restored: filmcraft_ui_egui::state::UiState = serde_json::from_value(saved).unwrap(); + assert_eq!(restored.panels.inspector_source, filmcraft_ui_egui::panels::panel_state::InspectorSource::Project); + assert_eq!(restored.panels.scopes, ui.panels.scopes); +} + +#[test] +fn middle_closing_an_inactive_project_tab_keeps_the_inspector_target() { + let (mut d, _, b, _) = inspector_selection_driver(); + let name = d.app().session.project.item(b).unwrap().name.clone(); + d.click("panel.tab.Info"); + assert_eq!(d.label("info.item"), name); + assert!(d.has("panel.tab.Project"), "the inactive Project tab must be visible before closing it"); + let project = serde_json::to_value(&d.app().session.project).unwrap(); + let state = d.exec("state.inspect", json!({})); + let history = d.exec("history.list", json!({})); + let revision = d.app().session.revision; + + d.ok("ui.click", json!({"id":"panel.tab.Project", "button":"middle"})); + d.frames(3); + assert!(!d.has("panel.tab.Project"), "middle-click closes the inactive tab"); + assert_eq!(serde_json::to_value(&d.app().session.project).unwrap(), project); + assert_eq!(d.exec("state.inspect", json!({})), state); + assert_eq!(d.exec("history.list", json!({})), history); + assert_eq!(d.app().session.revision, revision); + let focused = d.app().ui.focused; + let source = d.app().ui.panels.inspector_source; + assert_eq!(d.label("info.item"), name, "closing a tab must not select its stale item; focused={focused:?}, source={source:?}"); +} From ddaf78c221d2d477a7fbaa8fe858f4ab53942214 Mon Sep 17 00:00:00 2001 From: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:21:48 +0200 Subject: [PATCH 52/73] Keep transform handles in reach when the picture is past the monitor edge The Program monitor's transform box drew and hit-tested its handles at their true positions only, and the painter and the ui clip stop at the picture area. A still moved to or past the frame edge left its resize handles off the monitor. Handles beyond the monitor now stop at its edge (the picture itself is not moved), and the box is drawn over the whole monitor rather than only the picture. Fixes #1393 Co-Authored-By: Claude Opus 5.5 --- .../ui-egui/src/panels/transform_handles.rs | 77 +++++++++++++++---- crates/ui-egui/tests/transform_handles_ui.rs | 24 ++++++ 2 files changed, 86 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/src/panels/transform_handles.rs b/crates/ui-egui/src/panels/transform_handles.rs index 6470544c..c31d989c 100644 --- a/crates/ui-egui/src/panels/transform_handles.rs +++ b/crates/ui-egui/src/panels/transform_handles.rs @@ -43,6 +43,9 @@ const BADGE_ARM: f32 = 5.0; const ROTATE_REACH: f32 = 24.0; /// The `merge` key of a drag's changes: one undo step, whichever parameters the drag changes. const MERGE: &str = "programTransform"; +/// How far inside the monitor's clip (which reaches 12 points past the picture area) a handle +/// that would be off the monitor stops, so that all of it shows and can be grabbed. +const REACH_INSET: f32 = 12.0 + CORNER; /// What a press on the box takes hold of. #[derive(Clone, Copy, Debug, PartialEq)] @@ -64,6 +67,9 @@ struct Placement { to_screen: Affine, /// The picture's box: TL, TR, BR, BL. quad: [Pos2; 4], + /// Where the eight handles (see [`handle_points`]) are drawn and grabbed: on the box, or at + /// the monitor's edge for those beyond it (see [`within`]). + handles: [Pos2; 8], anchor_at: Pos2, position: Vec2, anchor: Vec2, @@ -117,9 +123,19 @@ fn inside(quad: &[Pos2; 4], p: Pos2) -> bool { pos != neg } +/// The handles at `points` pulled into `view`, the part of the monitor on screen: a picture moved +/// past the monitor's edge keeps every handle in reach (#1393). The picture stays where it is; +/// only the squares stop at the edge. A `view` with no room leaves them where they are. +fn within(points: [Pos2; 8], view: Rect) -> [Pos2; 8] { + if !(view.is_finite() && view.is_positive()) { + return points; + } + points.map(|p| pos2(p.x.max(view.min.x).min(view.max.x), p.y.max(view.min.y).min(view.max.y))) +} + /// The box of effect `e` (Motion or Transform) of clip `it` at media time `mt`, on the monitor -/// picture `pic` showing a `frame`-sized sequence. -fn placement(app: &FilmcraftApp, it: &TrackItem, e: &EffectInstance, mt: Tick, pic: Rect, frame: (u32, u32)) -> Option { +/// picture `pic` showing a `frame`-sized sequence, with its handles kept inside `reach`. +fn placement(app: &FilmcraftApp, it: &TrackItem, e: &EffectInstance, mt: Tick, pic: Rect, reach: Rect, frame: (u32, u32)) -> Option { let seq = app.session.active_sequence()?; let project = &app.session.project; let size = filmcraft_render::source_size(project, it.item).unwrap_or(frame); @@ -156,6 +172,7 @@ fn placement(app: &FilmcraftApp, it: &TrackItem, e: &EffectInstance, mt: Tick, p outer, to_screen, quad, + handles: within(handle_points(&quad), reach), anchor_at, position, anchor, @@ -168,7 +185,7 @@ fn placement(app: &FilmcraftApp, it: &TrackItem, e: &EffectInstance, mt: Tick, p /// The selected effect (its clip and index) and its box, while it is Motion or Transform of a /// selected clip under the playhead. -fn selected_placement(app: &FilmcraftApp, pic: Rect, frame: (u32, u32)) -> Option<(Target, &'static EffectDef, Placement)> { +fn selected_placement(app: &FilmcraftApp, pic: Rect, reach: Rect, frame: (u32, u32)) -> Option<(Target, &'static EffectDef, Placement)> { let sel = app.session.state.selected_effect.as_ref()?; if !app.session.state.selection.contains(&sel.clip) { return None; @@ -182,7 +199,7 @@ fn selected_placement(app: &FilmcraftApp, pic: Rect, frame: (u32, u32)) -> Optio let index = sel.index(&it.effects)?; let e = it.effects.get(index).filter(|e| e.enabled)?; let def = e.def().filter(|d| d.id == "motion" || d.id == "transform")?; - let pl = placement(app, it, e, it.source_time_at(ph), pic, frame)?; + let pl = placement(app, it, e, it.source_time_at(ph), pic, reach, frame)?; Some(((sel.clip, index), def, pl)) } @@ -190,7 +207,7 @@ fn grab_at(pl: &Placement, p: Pos2) -> Option { if (pl.anchor_at - p).length() <= ANCHOR_GRAB { return Some(Grab::Anchor); } - if let Some(n) = handle_points(&pl.quad).iter().position(|h| (*h - p).length() <= GRAB) { + if let Some(n) = pl.handles.iter().position(|h| (*h - p).length() <= GRAB) { return Some(Grab::Handle(n)); } if inside(&pl.quad, p) { @@ -282,24 +299,26 @@ fn angle_deg(v: egui::Vec2) -> f64 { f64::from(v.y.atan2(v.x).to_degrees()) } -fn draw(app: &mut FilmcraftApp, ui: &egui::Ui, pl: &Placement, pic: Rect, name: &str) { +/// Draws the box over the whole monitor (the ui's clip), not just the picture, so its handles show +/// beside the frame as well as on it. +fn draw(app: &mut FilmcraftApp, ui: &egui::Ui, pl: &Placement, name: &str) { let accent = app.tokens.accent; - let painter = ui.painter().with_clip_rect(pic.expand(12.0)); + let painter = ui.painter(); painter.add(egui::Shape::closed_line(pl.quad.to_vec(), Stroke::new(1.0, accent))); app.auto.add("program.transform.box", Rect::from_points(&pl.quad), name); let mid = centre(&pl.quad); // automation rects: the square inside the circle a press grabs in let grab = vec2(GRAB, GRAB) * std::f32::consts::SQRT_2; - for (n, c) in pl.quad.iter().enumerate() { + for (n, (q, c)) in pl.quad.iter().zip(&pl.handles).enumerate() { let hr = Rect::from_center_size(*c, vec2(CORNER, CORNER)); painter.rect_filled(hr, 0.0, Color32::WHITE); painter.rect_stroke(hr, 0.0, Stroke::new(1.0, accent), StrokeKind::Middle); app.auto.add(&format!("program.transform.handle.{n}"), Rect::from_center_size(*c, grab), "scale handle"); // just outside the corner, away from the box's centre - let out = (*c - mid).normalized(); - app.auto.add(&format!("program.transform.rotate.{n}"), Rect::from_center_size(*c + out * 14.0, vec2(10.0, 10.0)), "rotate"); + let out = (*q - mid).normalized(); + app.auto.add(&format!("program.transform.rotate.{n}"), Rect::from_center_size(*q + out * 14.0, vec2(10.0, 10.0)), "rotate"); } - for (n, m) in handle_points(&pl.quad).iter().enumerate().skip(4) { + for (n, m) in pl.handles.iter().enumerate().skip(4) { painter.rect_filled(Rect::from_center_size(*m, vec2(EDGE, EDGE)), 0.0, Color32::WHITE); app.auto.add(&format!("program.transform.handle.{n}"), Rect::from_center_size(*m, grab), "scale handle"); } @@ -382,10 +401,14 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra let id = egui::Id::new("program-transform"); let drag_id = id.with("drag"); let double = ui.input(|i| i.pointer.button_double_clicked(egui::PointerButton::Primary)); - if let Some((sel, def, pl)) = selected_placement(app, pic, frame) { - draw(app, ui, &pl, pic, def.name); - // (as far again beyond the rotate zone, where a click lets go of the box) - let resp = ui.interact(Rect::from_points(&pl.quad).expand(ROTATE_REACH * 2.0), id, Sense::click_and_drag()); + // the monitor around the picture as well as on it (its clip reaches past the picture area) + let reach = ui.clip_rect().shrink(REACH_INSET); + if let Some((sel, def, pl)) = selected_placement(app, pic, reach, frame) { + draw(app, ui, &pl, def.name); + // (as far again beyond the rotate zone, where a click lets go of the box; and the handles + // held at the monitor's edge) + let area = Rect::from_points(&pl.quad).union(Rect::from_points(&pl.handles)); + let resp = ui.interact(area.expand(ROTATE_REACH * 2.0), id, Sense::click_and_drag()); // the left button only: a middle or right drag leaves the picture alone let left = egui::PointerButton::Primary; if resp.drag_started_by(left) { @@ -442,3 +465,27 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra } crate::panels::effect_controls::run(app, ui.ctx(), actions); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn handles_past_the_monitor_stop_at_its_edge() { + let view = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 50.0)); + // a box from the middle of the view to well past its bottom-right corner + let quad = [pos2(50.0, 25.0), pos2(150.0, 25.0), pos2(150.0, 75.0), pos2(50.0, 75.0)]; + let h = within(handle_points(&quad), view); + assert_eq!(h[0], pos2(50.0, 25.0), "a handle on the monitor stays on the box"); + assert_eq!(h[1], pos2(100.0, 25.0)); + assert_eq!(h[2], pos2(100.0, 50.0)); + assert_eq!(h[3], pos2(50.0, 50.0)); + assert_eq!(h[5], pos2(100.0, 50.0)); + assert!(h.iter().all(|p| view.contains(*p))); + // no room to keep them in: left where they are, without crossed bounds + let none = Rect::from_min_max(pos2(10.0, 10.0), pos2(0.0, 0.0)); + assert_eq!(within(handle_points(&quad), none), handle_points(&quad)); + let nan = Rect::from_min_max(pos2(f32::NAN, 0.0), pos2(100.0, 50.0)); + assert_eq!(within(handle_points(&quad), nan), handle_points(&quad)); + } +} diff --git a/crates/ui-egui/tests/transform_handles_ui.rs b/crates/ui-egui/tests/transform_handles_ui.rs index c3c772a5..5eb1f9e2 100644 --- a/crates/ui-egui/tests/transform_handles_ui.rs +++ b/crates/ui-egui/tests/transform_handles_ui.rs @@ -441,6 +441,30 @@ fn guides_masks_and_graphics_take_the_left_button_only() { d.drag_from(at, 40.0, 20.0); assert_ne!(project(&d), before, "the left button still moves the layer"); } +/// A picture moved past the edge of the frame (and the monitor) keeps its handles in reach: the +/// ones that would be off the monitor stop at its edge, and dragging them still scales (#1393). +#[test] +fn handles_stay_in_reach_when_the_picture_is_past_the_edge() { + let mut d = Driver::demo(); + let (clip, _) = d.first_clip(); + // centred on the frame's bottom-right corner: the right and bottom handles are beyond it + d.exec("effects.setParam", json!({"clip": clip, "effect": "motion", "param": "position", "value": [1920.0, 1080.0]})); + d.click("effectControls.effect.motion"); + let pic = d.rect("program.picture"); + let (cx, cy) = d.center("program.transform.handle.0"); + assert!(near((cx, cy), (pic[0] + pic[2] / 2.0, pic[1] + pic[3] / 2.0)), "the top-left corner is at the frame's centre"); + let (hx, hy) = d.center("program.transform.handle.2"); + assert!(hx < 1600.0 && hy < 980.0, "the bottom-right handle is on screen: ({hx}, {hy})"); + // (its true place is about 280 points right of the picture, past the monitor) + assert!(hx <= pic[0] + pic[2] + 40.0 && hy <= pic[1] + pic[3] + 40.0, "and on the monitor: ({hx}, {hy}), picture {pic:?}"); + let p0 = d.point(clip, "motion", "position"); + d.drag("program.transform.handle.2", -30.0, -30.0); + let s = d.num(clip, "motion", "scale"); + assert!(s < 95.0, "the bottom-right handle scales: {s}"); + assert_eq!(d.point(clip, "motion", "position"), p0, "the picture is not moved"); + d.shot("transform-past-the-edge"); +} + /// Anywhere just outside the box rotates, beside the middle of an edge as beside a corner. #[test] fn rotating_from_beside_an_edge() { From e22a49db1c154bb3b2be1968672ac9d433fe75b9 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 00:25:27 +0200 Subject: [PATCH 53/73] fix(audio): crossfade audio transitions through the clip handles (fixes #1473) --- crates/render/src/audio.rs | 31 ++++++++++++++++++---- crates/render/src/mixer_tests.rs | 44 ++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/crates/render/src/audio.rs b/crates/render/src/audio.rs index fd211c24..cedc9dab 100644 --- a/crates/render/src/audio.rs +++ b/crates/render/src/audio.rs @@ -109,9 +109,11 @@ pub(crate) fn track_input_at( if !tr.range().overlaps(&range) { continue; } - // Re-mix: recompute the covered samples with crossfade gains. - let from = tr.from.and_then(|id| track.item(id)); - let to = tr.to.and_then(|id| track.item(id)); + // Re-mix: recompute the covered samples with crossfade gains. Both sides play over the + // whole transition, into their handles as the picture does, so a transition centred on a + // cut overlaps the two clips instead of only fading each on its own side of the cut. + let from = tr.from.and_then(|id| track.item(id)).map(|i| with_handles(i, tr.start, tr.end())); + let to = tr.to.and_then(|id| track.item(id)).map(|i| with_handles(i, tr.start, tr.end())); let s0 = tr.start.to_units_floor(sr as i64).max(start); let s1 = tr.end().to_units_floor(sr as i64).min(start + frames as i64); if s1 <= s0 { @@ -124,10 +126,10 @@ pub(crate) fn track_input_at( } let mut a = AudioBuffer::silence(sr, w, n); let mut b = AudioBuffer::silence(sr, w, n); - if let Some(f) = from { + if let Some(f) = &from { mix_item(project, f, s0, n, sr, sources, &mut a, 1.0, &live); } - if let Some(tt) = to { + if let Some(tt) = &to { mix_item(project, tt, s0, n, sr, sources, &mut b, 1.0, &live); } let dur = (tr.end() - tr.start).to_units_floor(sr as i64).max(1) as f32; @@ -143,6 +145,25 @@ pub(crate) fn track_input_at( tbuf } +/// `item` lengthened to cover `[t0, t1)` with its media timing unchanged: the part before its in +/// point and after its out point plays the source's handles (silent where the source has none). +/// Remixed clips have no linear media to extend and stay as they are. +fn with_handles(item: &TrackItem, t0: Tick, t1: Tick) -> TrackItem { + let mut ext = item.clone(); + if crate::remix::is_remixed(item) { + return ext; + } + let head = (item.start - t0).max(Tick::ZERO); + let tail = (t1 - item.end()).max(Tick::ZERO); + ext.start = item.start - head; + ext.duration = item.duration + head + tail; + // forward media time is source_in + (t - start)·speed; reversed it counts down from the out + // point, so the extra head (forward) or tail (reverse) moves source_in back + let shift = if item.reverse { tail } else { head }; + ext.source_in = item.source_in - Tick((shift.0 as f64 * item.speed.abs()).round() as i64); + ext +} + /// Held Clip Mixer controls of one track. #[derive(Default)] struct ClipLive { diff --git a/crates/render/src/mixer_tests.rs b/crates/render/src/mixer_tests.rs index e84737b6..6f471640 100644 --- a/crates/render/src/mixer_tests.rs +++ b/crates/render/src/mixer_tests.rs @@ -7,6 +7,7 @@ use filmcraft_media::{AudioStreamInfo, FrameRequest, MediaError, MediaInfo, Medi use filmcraft_project::mixer::{LANE_MUTE, LANE_PAN, LANE_VOLUME, send_lane}; use filmcraft_project::{ AudioChannels, AutomationMode, ItemId, ItemKind, Keyframe, Label, MediaClip, MediaRef, ParamValue, Project, SequenceSettings, TrackKind, TrackSend, + Transition, TransitionId, }; use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick, TimeRange}; @@ -406,6 +407,49 @@ fn audio_transition_curves() { } } +/// A transition centred on a cut mixes both clips across its whole length: the outgoing clip plays +/// on past its out point and the incoming one starts before its in point (their handles), as the +/// picture does, so the two overlap instead of each just dipping at the cut. The incoming source is +/// a ramp, so its handle must come from the media just before its in point. +#[test] +fn audio_transition_on_a_cut_crossfades_through_the_handles() { + fn ramp(i: i64, _: usize) -> f32 { + i as f32 * 1e-6 + } + let mut r = Rig::new(&[dc1, ramp]); + let (a_src, b_src) = (r.track(0).items[0].item, r.track(1).items[0].item); + let sec = |s: f64| Tick::from_seconds_f64(s); + let a = r.p.make_track_item(a_src, TrackKind::Audio, Tick::ZERO, TimeRange::new(Tick::ZERO, sec(1.0)), FrameRate::FPS_24).unwrap(); + let b = r.p.make_track_item(b_src, TrackKind::Audio, sec(1.0), TimeRange::new(sec(2.0), sec(1.0)), FrameRate::FPS_24).unwrap(); + let effect = filmcraft_project::vtransition::find_transition("constant_gain", filmcraft_project::EffectKind::AudioTransition).unwrap().instance(); + let tr = Transition { + id: TransitionId(1), + effect, + start: sec(0.75), + duration: sec(0.5), + from: Some(a.id), + to: Some(b.id), + align: Default::default(), + reverse: false, + }; + r.track(1).items.clear(); + let t0 = r.track(0); + t0.items = vec![a, b]; + t0.transitions.push(tr); + // the incoming clip plays media sample 96 000 at the cut (sample 48 000) + let b_at = |s: i64| ramp(s + 48_000, 0); + // constant gain at p = 0.25, 0.5 (the cut), 0.75: (1 - p)·A + p·B + for (s, p) in [(42_000, 0.25), (48_000, 0.5), (54_000, 0.75)] { + let want = (1.0 - p) * 0.1 + p * b_at(s); + let [l, rr] = r.mix(s, 1); + assert!(close(l[0], want, 1e-5) && close(rr[0], want, 1e-5), "sample {s}: {} (want {want})", l[0]); + } + // outside the transition each clip plays alone, at full level + for (s, want) in [(35_000, 0.1), (61_000, b_at(61_000))] { + assert!(close(r.mix(s, 1)[0][0], want, 1e-6), "sample {s}"); + } +} + // ------------------------------------------------------------------------------------- 5.1 /// Distinct DC level per source channel: 0.1, 0.2, … (L, R, C, LFE, Ls, Rs for 6-channel sources). From fa7c6ff8abd0ce06e0b4860705cf0b9e6d41001d Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:42:29 +1000 Subject: [PATCH 54/73] ingest: preserve same-named camera takes during ingest Co-authored-by: Codex --- crates/engine/src/proxies.rs | 70 +++++++++++--- crates/engine/src/proxies_tests.rs | 149 +++++++++++++++++++++++++++++ docs/project-files.md | 5 + 3 files changed, 210 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/proxies.rs b/crates/engine/src/proxies.rs index d602102f..f7ca3cfa 100644 --- a/crates/engine/src/proxies.rs +++ b/crates/engine/src/proxies.rs @@ -378,6 +378,56 @@ pub fn create(s: &mut Session, p: &Value) -> Result { Ok(json!({"job": job, "preset": pr.id, "outputs": outputs, "skipped": skipped})) } +/// Copy to a new file, numbering collisions without replacing another take. Open the input +/// first and create the output exclusively, so a failed read or a competing copy cannot replace +/// an existing destination. Relink only after the copy's identity has been checked. +fn copy_for_ingest(s: &mut Session, item: ItemId, path: &str, dir: &Path) -> Result { + let source = Path::new(path); + let name = source.file_name().ok_or_else(|| bad("ingest", "source has no file name"))?; + let mut input = std::fs::File::open(source).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + std::fs::create_dir_all(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; + let mut number = 1u64; + let (out, mut output) = loop { + let mut next = if number == 1 { name.to_os_string() } else { source.file_stem().unwrap_or(name).to_os_string() }; + if number != 1 { + next.push(format!(" {number}")); + if let Some(extension) = source.extension() { + next.push("."); + next.push(extension); + } + } + let out = dir.join(next); + match std::fs::OpenOptions::new().write(true).create_new(true).open(&out) { + Ok(output) => break (out, output), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { + number = number.checked_add(1).ok_or_else(|| bad("ingest", "no unused destination name"))?; + } + Err(e) => return Err(EngineError::Other(format!("{}: {e}", out.display()))), + } + }; + let copied = std::io::copy(&mut input, &mut output).and_then(|_| output.set_permissions(input.metadata()?.permissions())); + drop(output); + let out_s = out.to_string_lossy().into_owned(); + let linked = copied.map_err(|e| EngineError::Other(format!("copying {path}: {e}"))).and_then(|_| { + let a = crate::relink::identity_of(&*s.services, path).map_err(|e| EngineError::Other(e.to_string()))?; + let b = crate::relink::identity_of(&*s.services, &out_s).map_err(|e| EngineError::Other(e.to_string()))?; + if a != b { + return Err(EngineError::Other(format!("the copy of {} doesn't match the original", name.to_string_lossy()))); + } + // The generated suffix may change the name; every other relink check still applies. + crate::relink::relink(s, &json!({"item": item.0, "path": out_s, "relinkOthers": false, "match": {"fileName": false}}))?; + Ok(()) + }); + if let Err(error) = linked { + // Only this operation's exclusively created file can be removed on failure. + if let Err(cleanup) = std::fs::remove_file(&out) { + return Err(EngineError::Other(format!("{error}; removing incomplete copy {}: {cleanup}", out.display()))); + } + return Err(error); + } + Ok(out_s) +} + /// Ingest freshly imported items per the project's ingest settings. pub fn ingest(s: &mut Session, items: &[ItemId]) -> Result { let ing = s.project.settings.ingest.clone(); @@ -393,21 +443,10 @@ pub fn ingest(s: &mut Session, items: &[ItemId]) -> Result { Some(d) if !d.is_empty() => PathBuf::from(d), _ => Path::new(&path).parent().unwrap_or(Path::new(".")).join("Ingested Media"), }; - let fname = Path::new(&path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - let out = dir.join(&fname); - if Path::new(&path) == out { + if Path::new(&path).file_name().is_some_and(|name| Path::new(&path) == dir.join(name)) { continue; } - std::fs::create_dir_all(&dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; - std::fs::copy(&path, &out).map_err(|e| EngineError::Other(format!("copying {fname}: {e}")))?; - let out_s = out.to_string_lossy().into_owned(); - // verify the copy before switching to it - let a = crate::relink::identity_of(&*s.services, &path).map_err(|e| EngineError::Other(e.to_string()))?; - let b = crate::relink::identity_of(&*s.services, &out_s).map_err(|e| EngineError::Other(e.to_string()))?; - if a != b { - return Err(EngineError::Other(format!("the copy of {fname} doesn't match the original"))); - } - crate::relink::relink(s, &json!({"item": i.0, "path": out_s, "relinkOthers": false}))?; + let out_s = copy_for_ingest(s, i, &path, &dir)?; copied.push(json!({"item": i.0, "path": out_s})); } } @@ -423,6 +462,7 @@ pub fn ingest(s: &mut Session, items: &[ItemId]) -> Result { let pr = preset(if ing.preset.is_empty() { DEFAULT_TRANSCODE_PRESET } else { ing.preset.as_str() }).ok_or_else(|| bad("ingest", "unknown preset"))?; let mut work = Vec::new(); + let mut taken = Vec::new(); for &i in items { let Some((clip, path, name)) = media_path(&s.project, i) else { continue }; if clip.info.video.is_none() { @@ -433,7 +473,9 @@ pub fn ingest(s: &mut Session, items: &[ItemId]) -> Result { _ => Path::new(&path).parent().unwrap_or(Path::new(".")).join("Ingested Media"), }; let stem = Path::new(&path).file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or(name); - work.push((i, unique_path(&dir, &stem, "", pr.extension, &[]).to_string_lossy().into_owned(), pr)); + let out = unique_path(&dir, &stem, "", pr.extension, &taken).to_string_lossy().into_owned(); + taken.push(out.clone()); + work.push((i, out, pr)); } if !work.is_empty() { let id = start_job(s, format!("Ingest: transcode {} clip(s)", work.len()), work, OnDone::ReplaceMedia, false)?; diff --git a/crates/engine/src/proxies_tests.rs b/crates/engine/src/proxies_tests.rs index 6cff74e3..712834cf 100644 --- a/crates/engine/src/proxies_tests.rs +++ b/crates/engine/src/proxies_tests.rs @@ -124,6 +124,155 @@ fn ingest_copies_and_creates_proxies_on_import() { let _ = std::fs::remove_dir_all(&root); } +/// Import two different camera takes with the same basename into a folder that already has +/// that name. Check the saved project with a new media pool so cached decoders cannot hide a +/// replaced file. +fn check_ingest_collision(action: &str) { + use filmcraft_media::FrameRequest; + use filmcraft_project::{ItemId, MediaRef}; + use filmcraft_time::FrameRate; + use std::collections::BTreeSet; + use std::sync::atomic::Ordering; + + let root = tmp_dir(&format!("ingest-collision-{action}")); + let paths = [root.join("Card A/A001.mov"), root.join("Card B/A001.mov")]; + for (path, scene) in paths.iter().zip([DemoScene::Aurora, DemoScene::Plasma]) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + make_movie(path, scene, 160, 96, 4); + } + let original: Vec> = paths.iter().map(|p| std::fs::read(p).unwrap()).collect(); + assert_ne!(original[0], original[1], "different source takes"); + let dest = root.join("Ingest"); + std::fs::create_dir_all(&dest).unwrap(); + let existing = dest.join("A001.mov"); + let existing_bytes = b"existing destination must survive ingest"; + std::fs::write(&existing, existing_bytes).unwrap(); + let mut s = Session::default(); + s.execute("project.ingestSettings", json!({"enabled": true, "action": action, "destination": dest.to_string_lossy(), "preset": "prores_proxy_half"})) + .unwrap(); + let result = s.execute("file.import", json!({"paths": paths.iter().map(|p| p.to_string_lossy()).collect::>()})).unwrap(); + assert_eq!(result["errors"], json!([]), "{result}"); + let items: Vec = result["items"].as_array().unwrap().iter().map(|i| ItemId(i.as_u64().unwrap())).collect(); + assert_eq!(items.len(), 2, "both takes imported"); + if let Some(id) = result["ingest"]["job"]["job"].as_u64() { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + loop { + let job = s.jobs.iter().find(|j| j.id == id).unwrap(); + if job.progress.finished.load(Ordering::Relaxed) && job.result.lock().unwrap().is_some() { + assert!(job.result.lock().unwrap().as_ref().unwrap().is_ok(), "ingest job failed"); + break; + } + assert!(std::time::Instant::now() < deadline, "ingest job did not finish"); + std::thread::sleep(std::time::Duration::from_millis(25)); + } + s.poll_persistence(); + } + assert_eq!(std::fs::read(&existing).unwrap(), existing_bytes, "ingest must preserve existing destination files"); + let project_path = root.join("ingested.fcproj"); + s.execute("file.save", json!({"path": project_path.to_string_lossy()})).unwrap(); + let mut reopened = Session::default(); + reopened.execute("file.open", json!({"path": project_path.to_string_lossy()})).unwrap(); + let request = FrameRequest { time: FrameRate::FPS_24.tick_of(2), scale: 1.0 }; + let mut linked = BTreeSet::new(); + let mut proxies = BTreeSet::new(); + for (index, item) in items.iter().enumerate() { + let media = reopened.project.item(*item).unwrap().as_media().unwrap(); + let MediaRef::File { path } = &media.media else { panic!("ingest must retain file media") }; + assert!(linked.insert(path.clone()), "different takes must have different ingest paths"); + assert_eq!(std::path::Path::new(path).parent(), Some(dest.as_path())); + assert_eq!(std::fs::read(&paths[index]).unwrap(), original[index], "source card is unchanged"); + if action != "transcode" { + assert_eq!(std::fs::read(path).unwrap(), original[index], "each copy retains its own take"); + assert_eq!(media.identity, Some(crate::relink::identity_of_bytes(&original[index]))); + } + let source = reopened.media.open_file(&paths[index].to_string_lossy(), &*reopened.services).unwrap(); + let expected = source.video_frame(request).unwrap(); + let ingested = reopened.source(*item).unwrap().video_frame(request).unwrap(); + let quality = |frame: &filmcraft_frame::VideoFrame| { + let rgba = image::RgbaImage::from_raw(frame.width, frame.height, frame.to_rgba8()).unwrap(); + let expected = image::RgbaImage::from_raw(expected.width, expected.height, expected.to_rgba8()).unwrap(); + let expected = image::imageops::resize(&expected, frame.width, frame.height, image::imageops::FilterType::Triangle); + psnr(rgba.as_raw(), expected.as_raw()) + }; + assert!(quality(&ingested) > 30.0, "saved project retains the correct picture for take {index}"); + if action == "copyAndCreateProxies" { + let Some(MediaRef::File { path }) = &media.proxy else { panic!("copy should have its proxy attached") }; + assert!(proxies.insert(path.clone()), "different takes must have different proxy paths"); + let proxy = reopened.media.open_file(path, &*reopened.services).unwrap().video_frame(request).unwrap(); + assert!(quality(&proxy) > 30.0, "proxy retains the correct picture for take {index}"); + } + } + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn ingest_copy_preserves_same_named_takes_and_existing_files() { + check_ingest_collision("copy"); +} + +#[test] +fn ingest_copy_and_proxy_preserves_same_named_takes_and_existing_files() { + check_ingest_collision("copyAndCreateProxies"); +} + +#[test] +fn ingest_transcode_preserves_same_named_takes_and_existing_files() { + check_ingest_collision("transcode"); +} + +#[test] +fn ingest_copy_keeps_sources_already_at_destination_and_extensionless_names() { + use filmcraft_project::{ItemId, MediaRef}; + + for (name, numbered) in [("Take.wav", "Take 2.wav"), ("Take", "Take 2")] { + let root = tmp_dir("ingest-copy-existing-source"); + let dest = root.join("Ingest"); + let card = root.join("Card"); + std::fs::create_dir_all(&dest).unwrap(); + std::fs::create_dir_all(&card).unwrap(); + let paths = [dest.join(name), card.join(name)]; + let takes = [filmcraft_media::wav::write_wav16(&[0.25; 960], 1, 48_000), filmcraft_media::wav::write_wav16(&[-0.25; 960], 1, 48_000)]; + for (path, bytes) in paths.iter().zip(&takes) { + std::fs::write(path, bytes).unwrap(); + } + let mut s = Session::default(); + s.execute("project.ingestSettings", json!({"enabled": true, "action": "copy", "destination": dest.to_string_lossy()})).unwrap(); + let result = s.execute("file.import", json!({"paths": paths.iter().map(|p| p.to_string_lossy()).collect::>()})).unwrap(); + assert_eq!(result["errors"], json!([]), "{result}"); + assert_eq!(result["items"].as_array().unwrap().len(), 2); + assert_eq!(result["ingest"]["copied"].as_array().unwrap().len(), 1, "source already at destination is kept"); + for (index, path) in [dest.join(name), dest.join(numbered)].iter().enumerate() { + let item = ItemId(result["items"][index].as_u64().unwrap()); + let media = s.project.item(item).unwrap().as_media().unwrap(); + assert_eq!(media.media, MediaRef::File { path: path.to_string_lossy().into_owned() }); + assert_eq!(std::fs::read(path).unwrap(), takes[index]); + assert_eq!(std::fs::read(&paths[index]).unwrap(), takes[index]); + } + assert_eq!(std::fs::read_dir(&dest).unwrap().count(), 2, "no unnecessary copies or trailing-dot filenames"); + let _ = std::fs::remove_dir_all(root); + } +} + +#[test] +fn ingest_copy_does_not_create_a_destination_when_the_source_is_missing() { + use filmcraft_project::ItemId; + + let root = tmp_dir("ingest-copy-missing-source"); + let source = root.join("Take.wav"); + std::fs::write(&source, filmcraft_media::wav::write_wav16(&[0.25; 960], 1, 48_000)).unwrap(); + let mut s = Session::default(); + let imported = s.execute("file.import", json!({"paths": [source.to_string_lossy()]})).unwrap(); + let item = ItemId(imported["items"][0].as_u64().unwrap()); + let dest = root.join("Ingest"); + s.execute("project.ingestSettings", json!({"enabled": true, "action": "copy", "destination": dest.to_string_lossy()})).unwrap(); + let before = s.project.clone(); + std::fs::rename(&source, root.join("Moved.wav")).unwrap(); + assert!(crate::proxies::ingest(&mut s, &[item]).is_err()); + assert_eq!(s.project, before, "failed copy does not relink the item"); + assert!(!dest.exists(), "no destination is created before the source can be opened"); + let _ = std::fs::remove_dir_all(root); +} + /// Measure: Program-monitor playback cost of 4K H.264 with and without proxies (sequential frames /// at ½ resolution). Run with `RAYON_NUM_THREADS=1` so wall time ≈ CPU time: /// `cargo test --release -p filmcraft-engine proxy_playback_perf_4k -- --ignored --nocapture`. diff --git a/docs/project-files.md b/docs/project-files.md index 0298571c..752f5f23 100644 --- a/docs/project-files.md +++ b/docs/project-files.md @@ -362,6 +362,11 @@ destination?, preset?}` (Project Settings ▸ Ingest) acts on every `file.import - **createProxies** makes proxies in the background and attaches them; - **copyAndCreateProxies** does both. +Copied files keep their names when available; collisions receive a numbered suffix before the +extension (`A001 2.mov`) and never replace an existing file. Transcodes reserve distinct output +names for every clip in the import batch. Copies keep their source fingerprint, and each proxy +stays attached to its corresponding take. + `file.import` reports this under `ingest`. ## Project Manager From eb81d531eb3455e2fcd73287eaf0b6137de2991d Mon Sep 17 00:00:00 2001 From: fabian-co Date: Mon, 12 Oct 2026 17:45:45 -0500 Subject: [PATCH 55/73] macOS: open the window on Intel Metal by skipping indirect-call validation wgpu validates indirect draw and dispatch arguments with a compute shader it compiles when the device is created. Metal on an Intel Iris Pro (MacBookPro11,3, macOS 12.7.6) fails to compile it ("Compiler encountered an internal error" in MTLCompilerService), the device is lost, and the window never opens. FilmCraft draws and dispatches nothing indirectly, so leave the check out of the instance flags. WGPU_VALIDATION_INDIRECT_CALL=1 turns it back on. VectorCraft does the same (storytold/vectorcraft#651). Co-Authored-By: Claude Opus 5.5 --- apps/filmcraft/src/graphics.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/apps/filmcraft/src/graphics.rs b/apps/filmcraft/src/graphics.rs index e79ace08..8a9b13a4 100644 --- a/apps/filmcraft/src/graphics.rs +++ b/apps/filmcraft/src/graphics.rs @@ -427,6 +427,11 @@ fn apply_for(options: &mut NativeOptions, mode: Mode, backend_override: Option Date: Tue, 13 Oct 2026 01:12:57 +0200 Subject: [PATCH 56/73] fix(ui): Space and other shortcuts keep working after dragging a pane divider (fixes #1482) --- crates/ui-egui/src/lib.rs | 18 ++++++++++- crates/ui-egui/tests/ui_dock_resize.rs | 44 ++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9a6393b8..39854447 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1433,6 +1433,15 @@ impl FilmcraftApp { self.menu_sent = items; } + /// Whether keyboard focus is on a dock divider or a floating panel's resize grip, as drawn last pass. + fn divider_focused(&self, ctx: &egui::Context) -> bool { + let Some(focused) = ctx.memory(|m| m.focused()) else { return false }; + self.auto + .previous + .iter() + .any(|e| e.response_id == Some(focused) && (e.id.starts_with("dock.gutter.") || (e.id.starts_with("panel.float.") && e.id.ends_with(".resize")))) + } + fn handle_shortcuts(&mut self, ctx: &egui::Context) { let workspaces = (dock::names(&self.workspaces), self.ui.workspace.clone()); if self.bindings_rev != self.session.shortcuts.revision || workspaces != self.menu_workspaces { @@ -1440,7 +1449,10 @@ impl FilmcraftApp { self.bindings = menus::bindings(self); self.bindings_rev = self.session.shortcuts.revision; } - if ctx.egui_wants_keyboard_input() || self.dialog == Some(Dialog::Shortcuts) { + // A pane divider or floating-panel grip keeps focus after a pointer drag so arrows can + // nudge it, but it takes no other key: Space and the other shortcuts keep working (#1482). + let divider_focused = self.divider_focused(ctx); + if (ctx.egui_wants_keyboard_input() && !divider_focused) || self.dialog == Some(Dialog::Shortcuts) { return; } // A focused text field takes every unmodified key, including the ones our shortest @@ -1498,6 +1510,10 @@ impl FilmcraftApp { if lets_text_field_keep(*m, text_focused) { continue; } + // The focused divider consumes the arrows itself (egui matches them with any Shift/Alt). + if divider_focused && matches!(k, egui::Key::ArrowLeft | egui::Key::ArrowRight | egui::Key::ArrowUp | egui::Key::ArrowDown) { + continue; + } if i.consume_key(*m, *k) { fire.push(id.clone()); } diff --git a/crates/ui-egui/tests/ui_dock_resize.rs b/crates/ui-egui/tests/ui_dock_resize.rs index 12129c95..2057ce26 100644 --- a/crates/ui-egui/tests/ui_dock_resize.rs +++ b/crates/ui-egui/tests/ui_dock_resize.rs @@ -93,3 +93,47 @@ fn focused_dock_resize_owns_arrows_and_keeps_pointer_dragging() { assert_eq!(first_size(&h), 428.0); assert_eq!(h.state().session.state.playheads, before_playheads); } + +fn press(h: &mut Harness<'_, FilmcraftApp>, key: Key) { + for pressed in [true, false] { + h.event(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }); + step(h); + } + frames(h, 2); +} + +#[test] +fn space_plays_after_a_pane_is_resized_with_the_pointer() { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).unwrap(); + let app = FilmcraftApp::new(session); + let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(move |_| app); + frames(&mut h, 4); + 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 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 }); + frames(&mut h, 3); + assert_eq!(first_size(&h), 420.0); + // The divider keeps keyboard focus so arrows can nudge it; that must not cost the editor its shortcuts. + assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(Id::new(("dock-gutter", String::new())))); + press(&mut h, Key::Space); + assert!(h.state().playback.playing, "Space must start playback after resizing a pane (#1482)"); + press(&mut h, Key::Space); + assert!(!h.state().playback.playing); +} From c1b1e860464e3b0122e6cfe3a49f91e1bbce0b97 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 01:16:52 +0200 Subject: [PATCH 57/73] fix(codecs): in-band parameter sets compare without trailing zero bytes (fixes #1483) ffmpeg keeps a trailing zero byte after the hvcC PPS that the in-band PPS lacks, so the byte-for-byte comparison read it as a parameter-set change and sent the stream to the software decoder from its first sample. A NAL unit never ends with 0x00 (H.264 7.4.1, H.265 7.4.2): those bytes are trailing_zero_8bits, so both sides are compared without them. The platform fallback test used a trailing zero byte as its changed SPS; it now changes nal_ref_idc. --- crates/codecs/src/hw.rs | 46 +++++++++++++++++++++++++------ crates/platform/tests/fallback.rs | 5 ++-- 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/crates/codecs/src/hw.rs b/crates/codecs/src/hw.rs index b73b963c..d14545bb 100644 --- a/crates/codecs/src/hw.rs +++ b/crates/codecs/src/hw.rs @@ -175,9 +175,10 @@ impl StreamInfo { /// with (parameter sets, a sequence header, a key frame of another format). pub fn parameters_changed(&self, sample: &[u8]) -> bool { match self { - Self::Nal(i) => { - i.nals(sample).into_iter().any(|n| n.first().is_some_and(|&h| i.is_parameter_set(i.nal_type(h))) && !i.parameter_sets.iter().any(|p| p == n)) - } + Self::Nal(i) => i.nals(sample).into_iter().any(|n| { + n.first().is_some_and(|&h| i.is_parameter_set(i.nal_type(h))) + && !i.parameter_sets.iter().any(|p| without_trailing_zeros(p) == without_trailing_zeros(n)) + }), Self::Frame(i) => i.parameters_changed(sample), } } @@ -199,6 +200,14 @@ impl StreamInfo { } } +/// A NAL unit without the zero bytes after its last byte: a NAL unit never ends with 0x00 +/// (H.264 7.4.1, H.265 7.4.2), so they are trailing_zero_8bits some muxers keep (ffmpeg in `hvcC`), not +/// part of the parameter set. +fn without_trailing_zeros(nal: &[u8]) -> &[u8] { + let end = nal.iter().rposition(|&b| b != 0).map_or(0, |i| i + 1); + nal.get(..end).unwrap_or_default() +} + /// Length-prefixed parameter-set entries (`u16` length + bytes) starting at `pos`. fn read_sets(rec: &[u8], pos: &mut usize, count: usize, out: &mut Vec>) -> Result<()> { for _ in 0..count { @@ -402,13 +411,12 @@ mod tests { } } - #[test] - fn nal_splitting_is_bounded() { - let info = NalStreamInfo { + fn hevc_info(parameter_sets: Vec>) -> NalStreamInfo { + NalStreamInfo { codec: NalCodec::Hevc, length_size: 4, highest_tid: None, - parameter_sets: Vec::new(), + parameter_sets, coded: (16, 16), crop: (0, 0, 16, 16), chroma_format_idc: 1, @@ -419,11 +427,33 @@ mod tests { color: filmcraft_color::ColorInfo::REC709, par: (1, 1), reorder: 0, - }; + } + } + + #[test] + fn nal_splitting_is_bounded() { + let info = hevc_info(Vec::new()); // CRA (21) then a length that runs past the end let s = [0, 0, 0, 2, 21 << 1, 1, 0xff, 0xff, 0xff, 0xff, 1]; assert_eq!(info.nal_types(&s), vec![21]); assert!(info.is_irap(&s)); assert!(!info.is_irap(&[0, 0, 0, 2, 1 << 1, 1])); } + + #[test] + fn parameter_sets_compare_without_trailing_zero_bytes() { + // the hvcC PPS of an ffmpeg-muxed file and the in-band one of its first sample (#1483) + let hvcc_pps = vec![0x44, 0x01, 0xe0, 0xf7, 0xc0, 0x4c, 0x90, 0x00]; + let info = StreamInfo::Nal(hevc_info(vec![hvcc_pps.clone()])); + let sample = |nal: &[u8]| { + let mut v = (nal.len() as u32).to_be_bytes().to_vec(); + v.extend_from_slice(nal); + v + }; + assert!(!info.parameters_changed(&sample(&hvcc_pps)), "the same PPS"); + assert!(!info.parameters_changed(&sample(&hvcc_pps[..7])), "a trailing zero byte is not part of the NAL unit"); + assert!(!info.parameters_changed(&sample(&[&hvcc_pps[..], &[0, 0]].concat())), "nor are several"); + assert!(info.parameters_changed(&sample(&hvcc_pps[..6])), "a missing byte that is not zero is a change"); + assert!(info.parameters_changed(&sample(&[0x44, 0x01, 0xe0, 0xf7, 0xc0, 0x4c, 0x91])), "another PPS is a change"); + } } diff --git a/crates/platform/tests/fallback.rs b/crates/platform/tests/fallback.rs index 95d8e430..8c00e52f 100644 --- a/crates/platform/tests/fallback.rs +++ b/crates/platform/tests/fallback.rs @@ -97,9 +97,10 @@ fn changed_in_band_parameter_sets_switch_to_software() { let out = decode_all(&mut d, &same); assert!(d.is_hardware()); assert_same("repeated SPS", &out, &decode_all(filmcraft_codecs::software_video_decoder(&s.entry).unwrap().as_mut(), &same)); - // a different SPS (same values, trailing zero byte) at the second sync sample: software + // a different SPS (same values, another nal_ref_idc: a trailing zero byte is not a change) at + // the second sync sample: software let mut changed_sps = sps.clone(); - changed_sps.push(0); + changed_sps[0] ^= 0x40; let k = (1..s.samples.len()).find(|&i| s.sync[i]).unwrap(); let changed: Vec<_> = s.samples.iter().enumerate().map(|(i, (x, p))| (if i == k { with_sps(&changed_sps, x) } else { x.clone() }, *p)).collect(); let mut d = hybrid(&s, usize::MAX); From 95297477fd3384e16dbf0da31dbf3f89a5878d73 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 01:31:43 +0200 Subject: [PATCH 58/73] timeline: a middle-button drag pans, whatever the tool (fixes #1490) The monitors already pan with the middle button (#824). In the Timeline a middle-button drag went down the left button's path and selected, moved, marqueed, scrubbed or cut, depending on the tool. It now starts the Hand tool's pan from where the button went down. --- crates/ui-egui/src/panels/timeline.rs | 7 + .../ui-egui/tests/timeline_middle_pan_ui.rs | 145 ++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 crates/ui-egui/tests/timeline_middle_pan_ui.rs diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index ac4caf53..f0a33db1 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2234,6 +2234,13 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & g => g, }; let started = match (tool, grab) { + // a middle-button drag pans whatever the tool and whatever it starts on, as in the + // monitors (#824) and DaVinci Resolve (#1490); only a primary press is kept in + // `pressed`, so the pan starts from where the button went down + _ if resp.drag_started_by(egui::PointerButton::Middle) => { + let last = ctx.input(|i| i.pointer.press_origin()).unwrap_or(p); + Some(Drag::Pan { last, start_scroll: app.ui.timeline.target_scroll, start_v: app.ui.timeline.v_scroll }) + } (_, Grab::Other(Hit::Ruler)) => Some(Drag::Scrub { initial: app.session.playhead() }), (Tool::Hand, _) => Some(Drag::Pan { last: p, start_scroll: app.ui.timeline.target_scroll, start_v: app.ui.timeline.v_scroll }), (Tool::Zoom, _) => { diff --git a/crates/ui-egui/tests/timeline_middle_pan_ui.rs b/crates/ui-egui/tests/timeline_middle_pan_ui.rs new file mode 100644 index 00000000..a9426616 --- /dev/null +++ b/crates/ui-egui/tests/timeline_middle_pan_ui.rs @@ -0,0 +1,145 @@ +//! Headless UI tests of the middle mouse button in the Timeline (#1490): a middle-button drag pans +//! the view, whatever the tool and whatever it starts on, and never selects, moves or cuts a clip. +//! The Program and Source monitors already pan with it (#824). + +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) -> 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) { + self.harness.input_mut().events.push(e); + self.frames(1); + } + + fn middle(&mut self, pos: egui::Pos2, pressed: bool) { + self.send(egui::Event::PointerButton { pos, button: egui::PointerButton::Middle, pressed, modifiers: egui::Modifiers::NONE }); + } + + /// Drag with the middle button from `from` to `to`, in small steps, a frame each. + fn middle_drag(&mut self, from: egui::Pos2, to: egui::Pos2) { + self.send(egui::Event::PointerMoved(from)); + self.middle(from, true); + for k in 1..=8 { + self.send(egui::Event::PointerMoved(from.lerp(to, k as f32 / 8.0))); + } + self.middle(to, false); + self.frames(2); + } + + /// A clip of Video 1 in view, wide enough to start a drag on: its id, its start (ticks) and + /// its rectangle, with nothing selected and the view at a known zoom and scroll. + fn setup(&mut self) -> (u64, i64, egui::Rect) { + self.ok("ui.set", json!({"focused": "Timeline", "timeline": {"pps": 50.0, "scroll": 4.0}})); + self.frames(4); + self.exec("timeline.select", json!({"clips": []})); + let shown = self.ok("ui.elements", json!({"prefix": "timeline.clip."})); + let shown: Vec = shown.as_array().unwrap().iter().map(|e| e["id"].as_str().unwrap().to_string()).collect(); + let seq = self.exec("sequence.inspect", json!({})); + for it in seq["video"][0]["items"].as_array().unwrap().clone() { + let (clip, start) = (it["clip"].as_u64().unwrap(), it["start"].as_i64().unwrap()); + let id = format!("timeline.clip.{clip}"); + if shown.contains(&id) { + let r = self.rect(&id); + if r.width() > 40.0 && r.center().x > 600.0 { + return (clip, start, r); + } + } + } + panic!("no clip of Video 1 in view"); + } + + fn start_of(&mut self, clip: u64) -> i64 { + let seq = self.exec("sequence.inspect", json!({})); + let items = seq["video"][0]["items"].as_array().unwrap().clone(); + items.iter().find(|i| i["clip"] == clip).map(|i| i["start"].as_i64().unwrap()).unwrap_or_else(|| panic!("clip {clip} gone")) + } + + fn clips(&mut self) -> usize { + let seq = self.exec("sequence.inspect", json!({})); + seq["video"][0]["items"].as_array().unwrap().len() + } + + fn app(&mut self) -> &mut FilmcraftApp { + self.harness.state_mut() + } +} + +#[test] +fn a_middle_button_drag_on_a_clip_pans_and_leaves_the_clip_alone() { + let mut d = Driver::demo(); + let (clip, start, r) = d.setup(); + let from = r.center(); + d.middle_drag(from, from - egui::vec2(200.0, 0.0)); + let scroll = d.app().ui.timeline.target_scroll; + assert!((scroll - 8.0).abs() < 1e-6, "200 pt to the left at 50 pt a second scrolls 4 s later: {scroll}"); + assert!(d.app().session.state.selection.is_empty(), "the middle button selects nothing"); + assert_eq!(d.start_of(clip), start, "the clip stays where it was"); +} + +#[test] +fn a_middle_button_drag_pans_whatever_the_tool() { + let mut d = Driver::demo(); + d.ok("ui.set", json!({"tool": "Razor"})); + let (_, _, r) = d.setup(); + let before = d.clips(); + let from = r.center(); + d.middle_drag(from, from - egui::vec2(200.0, 0.0)); + let scroll = d.app().ui.timeline.target_scroll; + assert!((scroll - 8.0).abs() < 1e-6, "the Razor tool pans with the middle button too: {scroll}"); + assert_eq!(d.clips(), before, "the middle button cuts nothing"); +} From f87796f0c83600218e352838e8ae52c87508f66c Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 02:07:14 +0200 Subject: [PATCH 59/73] fix(menus): hovering another title switches the open menu (fixes #1500) --- crates/ui-egui/src/menus.rs | 39 +++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 04d79ac3..3b271d71 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -842,6 +842,7 @@ pub fn menu_bar(app: &mut FilmcraftApp, ui: &mut egui::Ui) { 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| { + let mut titles = Vec::with_capacity(MENUS.len()); for top in MENUS { let mine: Vec<&MenuItem> = items.iter().filter(|i| i.path.first().map(String::as_str) == Some(top)).collect(); let r = ui.menu_button(app.ui.language.tr(top), |ui| { @@ -852,13 +853,30 @@ pub fn menu_bar(app: &mut FilmcraftApp, ui: &mut egui::Ui) { menu_level(ui, &mine, 1, &mut clicked, &mut app.auto, &labels); }); app.auto.add(&format!("menu.{top}"), r.response.rect, top); + titles.push(r.response); } + switch_on_hover(ui.ctx(), &titles); }); if let Some(id) = clicked { let _ = invoke(app, &ctx, &id, json!({})); } } +/// Like a native menu bar (Windows, macOS): while one top-level menu is open, hovering another +/// top-level title opens that menu instead (#1500). egui's `MenuBar` only switches on click. +fn switch_on_hover(ctx: &egui::Context, titles: &[egui::Response]) { + let ids: Vec = titles.iter().map(egui::Popup::default_response_id).collect(); + let Some(open) = ids.iter().position(|id| egui::Popup::is_id_open(ctx, *id)) else { return }; + // `hovered` is false where a popup covers the title (a tall submenu pushed up over the + // bar), so the pointer inside an open menu never switches it. + if let Some(i) = titles.iter().position(egui::Response::hovered) + && i != open + { + egui::Popup::open_id(ctx, ids[i]); + ctx.request_repaint(); + } +} + /// 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( @@ -1014,4 +1032,25 @@ mod tests { 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"])); } + + /// #1500: like a native menu bar, while one menu is open hovering another title opens it. + #[test] + fn hovering_another_title_switches_the_open_menu() { + use egui_kittest::{Harness, kittest::Queryable}; + + let app = FilmcraftApp::new(filmcraft_engine::Session::default()); + let mut harness = Harness::builder().with_size(egui::vec2(1200.0, 700.0)).build_ui_state(|ui, app| menu_bar(app, ui), app); + harness.run_steps(3); + // Nothing open: hovering a title does not open it. + harness.get_by_label("Edit").hover(); + harness.run_steps(3); + assert!(harness.query_by_label_contains("Save All").is_none() && harness.query_by_label_contains("Undo").is_none()); + harness.get_by_label("File").click(); + harness.run_steps(3); + assert!(harness.query_by_label_contains("Save All").is_some(), "File menu did not open"); + harness.get_by_label("Edit").hover(); + harness.run_steps(4); + assert!(harness.query_by_label_contains("Save All").is_none(), "File menu should close"); + assert!(harness.query_by_label_contains("Undo").is_some(), "Edit menu should open on hover"); + } } From 3b3599d11c553f961cce6b8278f20c6d3d7d4dc9 Mon Sep 17 00:00:00 2001 From: Narmjep Date: Tue, 13 Oct 2026 02:27:47 +0200 Subject: [PATCH 60/73] fix(timeline): stop Ruler timestamps disappearing while zooming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Ruler walked one grid (the tick stride) and labelled a frame only when it was also a multiple of the label stride. Chosen independently, the two need not divide: at 24 fps and 100 pt a second, 5-frame ticks against 48-frame labels meet only every lcm(5, 48) = 240 frames — one timestamp per 10 s, the rest returning at the next zoom step, where the ladder happens to divide. Take the tick stride as the finest division of the label stride that still leaves ticks 12 pt apart, so both are one grid. Every label then falls on a tick and can no longer be skipped, and the ticks stay evenly spaced around it instead of bunching either side. Labels are >= 110 pt apart, so a divisor of at most 9 always meets the minimum. The labelled stride, and so every timecode and the frame it is drawn at, is unchanged. Strides are clamped to >= 1 (the loop advances by one and tests the other for divisibility), and the ladder's products saturate, as FrameRate is plain serde data from the project file. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/ui-egui/src/panels/timeline.rs | 81 +++++++++++++++++++-------- 1 file changed, 57 insertions(+), 24 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index ac4caf53..3557b294 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1355,6 +1355,22 @@ fn draw_headers(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, rows: } } +/// The Ruler's two strides, in frames: `(tick, label)`. +/// +/// `label` is the first ladder step wide enough to keep timestamps apart (110 pt); `tick` is the +/// finest divisor of `label` that still leaves ticks 12 pt apart. Deriving the tick stride as a +/// divisor puts every label on a tick and keeps the ticks between labels evenly spaced. +pub fn ruler_steps(frame_px: f64, base: i64) -> (i64, i64) { + // `FrameRate` is plain serde data, so a step past `base` can overflow; saturate instead. + let big = |k: i64| base.saturating_mul(k); + let steps = [1, 2, 5, 10, base / 2, base, big(2), big(5), big(10), big(15), big(30), big(60), big(120), big(300), big(600), big(1800), big(3600)]; + let label_step = (*steps.iter().find(|s| **s as f64 * frame_px >= 110.0).unwrap_or(&big(3600))).max(1); + // Labels are at least 110 pt apart, so a divisor no larger than 9 keeps ticks >= 12 pt. + let minor = + (1..=9).rev().filter(|k| label_step.rem_euclid(*k) == 0).map(|k| label_step / k).find(|m| *m as f64 * frame_px >= 12.0).unwrap_or(label_step).max(1); + (minor, label_step) +} + fn draw_top(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq: &Sequence, layout: &Layout, t: &Tokens, seq_id: ItemId) { let p = ui.painter().clone(); let rate = seq.settings.frame_rate; @@ -1448,30 +1464,7 @@ fn draw_top(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq: &Sequenc let marker_y = ruler.min.y + 2.0; let label_y = ruler.min.y + 24.0; let tick_base = ruler.max.y - 3.0; - let fps = rate.as_f64(); - let base = rate.timecode_base(); - let steps: Vec = vec![ - 1, - 2, - 5, - 10, - base / 2, - base, - base * 2, - base * 5, - base * 10, - base * 15, - base * 30, - base * 60, - base * 120, - base * 300, - base * 600, - base * 1800, - base * 3600, - ]; - let frame_px = layout.pps / fps; - let label_step = *steps.iter().find(|s| **s as f64 * frame_px >= 110.0).unwrap_or(&(base * 3600)); - let minor = *steps.iter().find(|s| **s as f64 * frame_px >= 12.0).unwrap_or(&label_step); + let (minor, label_step) = ruler_steps(layout.pps / rate.as_f64(), rate.timecode_base()); // In/Out band if seq.mark_in.is_some() || seq.mark_out.is_some() { let a = layout.x_of(seq.mark_in.unwrap_or(Tick::ZERO)); @@ -3202,3 +3195,43 @@ mod new_track_tests { } } } + +#[cfg(test)] +mod ruler_tests { + use super::ruler_steps; + + /// Every combination keeps both minimums and has the tick stride divide the label stride. + #[test] + fn labels_sit_on_evenly_spaced_ticks() { + for base in [24, 25, 30, 50, 60, 120] { + for frame_px in [0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 8.0, 20.0, 400.0] { + let (minor, label) = ruler_steps(frame_px, base); + let what = format!("{base} fps at {frame_px} pt a frame: tick {minor}, label {label}"); + assert!(minor >= 1, "{what}"); + assert_eq!(label % minor, 0, "every label must fall on a tick: {what}"); + assert!(minor as f64 * frame_px >= 12.0 || minor == label, "ticks at least 12 pt apart: {what}"); + assert!(label as f64 * frame_px >= 110.0, "labels at least 110 pt apart: {what}"); + } + } + } + + /// At 24 fps and 100 pt a second the ruler labels every 2 s, with evenly spaced ticks between. + #[test] + fn a_24_fps_view_at_100_pt_a_second_labels_every_two_seconds() { + let (minor, label) = ruler_steps(100.0 / 24.0, 24); + assert_eq!(label, 48, "2 s at 24 fps"); + assert_eq!(label % minor, 0); + assert!(minor as f64 * (100.0 / 24.0) >= 12.0); + } + + /// Degenerate inputs still give non-zero strides that divide. + #[test] + fn damaged_rates_give_usable_strides() { + for frame_px in [f64::NAN, f64::INFINITY, 0.0, -1.0] { + let (minor, label) = ruler_steps(frame_px, 24); + assert!(minor >= 1 && label >= 1 && label % minor == 0, "{frame_px} pt a frame: tick {minor}, label {label}"); + } + let (minor, label) = ruler_steps(1.0, i64::MAX); + assert!(minor >= 1 && label >= 1 && label % minor == 0, "an unrepresentable rate: tick {minor}, label {label}"); + } +} From 9203732393c28cf49c0bf94a84f8e062f1ca064c Mon Sep 17 00:00:00 2001 From: CokeRoach Date: Tue, 13 Oct 2026 09:40:52 +0900 Subject: [PATCH 61/73] Effects panel: keep the whole name of a folder that contains a slash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Effects panel drew a folder's name from the last `/`-separated part of its key (`/`), so Audio Effects ▸ Noise Reduction/Restoration showed as "Restoration" — truncated in English and untranslated in every other language, since the catalogs translate the full name. Split once, after the top folder, instead. Test: effects_panel_lists_the_premiere_transition_folders checks the label. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013XWUReif4gqcjU5GsjYioc --- crates/ui-egui/src/panels/effects.rs | 4 +++- crates/ui-egui/tests/scripted.rs | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/effects.rs b/crates/ui-egui/src/panels/effects.rs index df3e7d93..c4f5c15b 100644 --- a/crates/ui-egui/src/panels/effects.rs +++ b/crates/ui-egui/src/panels/effects.rs @@ -154,7 +154,9 @@ fn folder_row(app: &mut FilmcraftApp, ui: &mut egui::Ui, key: &str, depth: usize t.text_dim, ); icons::paint(ui.painter(), Rect::from_center_size(pos2(x + 18.0, r.center().y), vec2(13.0, 13.0)), Icon::Folder, t.text_dim); - let name = key.rsplit('/').next().unwrap_or(key); + // a sub-folder key is `/`; folder names may themselves contain a slash + // (Audio Effects ▸ Noise Reduction/Restoration), so split once, after the top folder + let name = if depth == 0 { key } else { key.split_once('/').map_or(key, |(_, folder)| folder) }; ui.painter().text(pos2(x + 30.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::t(name), Tokens::ui(12.0), t.text); app.auto.add(&format!("effects.folder.{key}"), r, name); if resp.clicked() { diff --git a/crates/ui-egui/tests/scripted.rs b/crates/ui-egui/tests/scripted.rs index b5f105c7..61b2b8be 100644 --- a/crates/ui-egui/tests/scripted.rs +++ b/crates/ui-egui/tests/scripted.rs @@ -231,6 +231,11 @@ fn effects_panel_lists_the_premiere_transition_folders() { for id in ["additive_dissolve_legacy", "clock_wipe_legacy", "cross_dissolve_legacy", "push_legacy", "whip_legacy"] { assert!(items.iter().any(|i| i == &format!("effects.item.{id}")), "{id}: {items:?}"); } + // a folder whose own name has a slash keeps all of it (Noise Reduction/Restoration, not Restoration) + d.ok("ui.set", json!({"effectsSearch": "denoise"})); + d.frames(3); + let noise = d.ok("ui.elements", json!({"prefix": "effects.folder.Audio Effects/Noise Reduction/Restoration"})); + assert_eq!(noise[0]["label"], json!("Noise Reduction/Restoration"), "{noise}"); // obsolete transitions stay out of the panel d.ok("ui.set", json!({"effectsSearch": "venetian"})); d.frames(3); From bcb7902f427d08be4033d20bc8a4e27f577c7664 Mon Sep 17 00:00:00 2001 From: jnohclee-rgb <336857373+jnohclee-rgb@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:56:52 +0800 Subject: [PATCH 62/73] fix(engine): preserve offline sources referenced through subclips Signed-off-by: jnohclee-rgb <336857373+jnohclee-rgb@users.noreply.github.com> --- crates/engine/src/commands.rs | 5 ++-- crates/engine/src/relink_tests.rs | 44 +++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index ce502fce..d85abe6a 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -3148,10 +3148,11 @@ 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() { + for it in s.project.sequences().filter(|it| !items.contains(&it.id)) { if let Some(q) = it.as_sequence() { for t in q.all_tracks() { - used.extend(t.items.iter().map(|x| x.item)); + // Subclips keep the underlying file source alive too. + used.extend(t.items.iter().filter_map(|x| s.project.resolve_media(x.item).map(|(id, _, _)| id))); } } } diff --git a/crates/engine/src/relink_tests.rs b/crates/engine/src/relink_tests.rs index d25dda91..3bafc74c 100644 --- a/crates/engine/src/relink_tests.rs +++ b/crates/engine/src/relink_tests.rs @@ -270,3 +270,47 @@ fn jobs_list_reports_an_exports_offline_media() { assert_eq!(j["offlineMedia"][0]["item"], json!(items[1].0), "{j}"); let _ = std::fs::remove_dir_all(&root); } + +#[test] +fn deleting_bin_keeps_subclip_source_but_removes_unused_media() { + let root = tmp_dir("delete-bin-used-media"); + let (path, items) = saved_project(&root); + let mut s = open(&path); + let p = std::sync::Arc::make_mut(&mut s.project); + let seq = s.state.active_sequence.unwrap(); + let range = filmcraft_time::TimeRange::new(filmcraft_time::Tick::ZERO, p.item(items[0]).unwrap().duration()); + let sub = + p.add_item("Subclip", filmcraft_project::Label::Iris, filmcraft_project::ItemKind::Subclip { parent: items[0], range, restrict_trims: false }, None); + for track in p.sequence_mut(seq).unwrap().all_tracks_mut() { + track.items.retain(|clip| clip.item != items[1]); + for clip in &mut track.items { + clip.item = sub; + } + } + let bin = p.add_bin("Sources", None); + p.move_to_bin(&items, Some(bin)).unwrap(); + let before = s.active_sequence().unwrap().clone(); + s.execute("project.delete", json!({"items": [bin.0, items[0].0, bin.0]})).unwrap(); + assert_eq!(s.active_sequence().unwrap(), &before); + assert!(s.project.root.find_bin(bin).is_none()); + assert!(s.project.item(items[0]).unwrap().as_media().unwrap().offline); + assert!(s.project.item(items[1]).is_none()); + assert!(s.project.resolve_media(sub).unwrap().1.offline); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.project.root.parent_of(items[0]), Some(bin)); + assert!(s.project.item(items[1]).is_some()); + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn deleting_sequence_and_its_media_does_not_keep_an_unused_source() { + let root = tmp_dir("delete-sequence-and-media"); + let (path, items) = saved_project(&root); + let mut s = open(&path); + let seq = s.state.active_sequence.unwrap(); + s.execute("project.delete", json!({"items": [seq.0, items[0].0]})).unwrap(); + assert!(s.project.item(seq).is_none()); + assert!(s.project.item(items[0]).is_none()); + assert!(s.project.item(items[1]).is_some()); + let _ = std::fs::remove_dir_all(root); +} From 2343fc8fa37e2afa6e4bca29cddf577987472e06 Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:07:57 +1000 Subject: [PATCH 63/73] captions: preserve speaker names in TTML metadata Co-authored-by: Codex --- crates/captions/README.md | 21 +++ crates/captions/src/ttml.rs | 115 +++++++++++-- crates/captions/tests/ttml_speakers.rs | 219 +++++++++++++++++++++++++ 3 files changed, 340 insertions(+), 15 deletions(-) create mode 100644 crates/captions/tests/ttml_speakers.rs diff --git a/crates/captions/README.md b/crates/captions/README.md index 909636fd..08cc4d0d 100644 --- a/crates/captions/README.md +++ b/crates/captions/README.md @@ -23,6 +23,27 @@ All cue times are exact `Tick`s. Milliseconds convert exactly (1 ms = 254 016 00 frames map through `FrameRate::FPS_29_97`. `Document::snap_to_frames` snaps cues to a sequence's frame grid (the engine does this on import) and removes overlaps. +### TTML speaker metadata + +TTML and DFXP exports define each distinct speaker label once in the document head and refer to +it with a stable, unique `ttm:agent` ID. Names are XML-escaped full-name text with preserved +whitespace; punctuation, Unicode and literal underscores survive a round trip. Agent type is +`other` because a caption's speaker label does not say whether it denotes a person or a group. + +On import, agent definitions directly under `head` or `head/metadata` resolve paragraph and timed +span speakers. Speaker metadata uses the standard TTML namespace or the April and October 2006 +TTaf1 draft namespaces. Agent IDs use `xml:id`, except in the April draft, which uses unqualified +`id`. Agent definitions from unrelated application metadata are ignored. Recognized `ttm:agent` +attributes take precedence over the legacy local-name `agent` fallback. A full name is preferred, +otherwise the first nonempty name (or an explicitly empty name) is used. Multiple agent references become one display label, joined with `, ` in +reference order; the project does not retain the individual agent identities. Older FilmCraft +files without agent definitions remain readable through the previous underscore-to-space +fallback. Other agent metadata, including actor relationships, is not retained. + +This mapping follows [W3C TTML1, third edition (2018), §§12.1.5–12.2.1](https://www.w3.org/TR/2018/REC-ttml1-20181108/#metadata-vocabulary-agent). +The [April 2006 TTaf1 draft, §§7.2.1 and 12.1.5–12.2.1](https://www.w3.org/TR/2006/WD-ttaf1-dfxp-20060427/) +defines its earlier metadata namespace and agent ID form. + ### SCC writer Each caption is sent as a pop-on load (RCL, ENM, then per row a preamble address code and tab diff --git a/crates/captions/src/ttml.rs b/crates/captions/src/ttml.rs index c16d6976..4367663a 100644 --- a/crates/captions/src/ttml.rs +++ b/crates/captions/src/ttml.rs @@ -8,12 +8,14 @@ //! offsets (`123f`) at `ttp:frameRate` / `ttp:frameRateMultiplier`, which are exact; without //! one, and always for DFXP (for older players), times are clock times with milliseconds. //! -//! **Reading** is namespace-agnostic (TTML1 2010, the 2006 `ttaf1` drafts and TTML2 all read): +//! **Reading** accepts TTML1 2010, the 2006 `ttaf1` drafts and TTML2: //! 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 `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 std::collections::{HashMap, HashSet}; + use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick}; use crate::{Cue, Document, Error, Result, TICKS_PER_MS}; @@ -31,6 +33,9 @@ const NS_TT: &str = "http://www.w3.org/ns/ttml"; const NS_TTP: &str = "http://www.w3.org/ns/ttml#parameter"; const NS_TTS: &str = "http://www.w3.org/ns/ttml#styling"; const NS_TTM: &str = "http://www.w3.org/ns/ttml#metadata"; +const NS_TTM_DRAFT: &str = "http://www.w3.org/2006/10/ttaf1#metadata"; +const NS_TTM_DRAFT_APRIL: &str = "http://www.w3.org/2006/04/ttaf1#metadata"; +const NS_XML: &str = "http://www.w3.org/XML/1998/namespace"; const IMSC1_TEXT: &str = "http://www.w3.org/ns/ttml/profile/imsc1/text"; const DFXP_PROFILE: &str = "http://www.w3.org/ns/ttml/profile/dfxp-presentation"; @@ -122,6 +127,10 @@ fn is_ncname(s: &str) -> bool { c.next().is_some_and(|f| f.is_alphabetic() || f == '_') && c.all(|x| x.is_alphanumeric() || matches!(x, '_' | '-' | '.')) } +fn cue_id(cue: &Cue, index: usize) -> String { + cue.id.as_deref().filter(|x| is_ncname(x)).map(str::to_string).unwrap_or_else(|| format!("c{}", index + 1)) +} + /// Write `doc` as TTML. `rate` (IMSC1 only) makes times exact frame offsets; `lang` is the /// document language (`xml:lang`). pub fn write(doc: &Document, flavor: Flavor, rate: Option, lang: &str) -> String { @@ -140,13 +149,38 @@ pub fn write(doc: &Document, flavor: Flavor, rate: Option, lang: &str } } o.push_str(&format!(" xml:lang=\"{}\">\n", esc(if lang.is_empty() { "en" } else { lang }))); + // Keep agent identifiers distinct from the IDs already emitted for cues, style and region. + let mut ids: HashSet = ["s1".into(), "r1".into()].into_iter().collect(); + ids.extend(doc.cues.iter().enumerate().map(|(i, c)| cue_id(c, i))); + let mut speakers = Vec::new(); + let mut speaker_ids = HashMap::new(); + for speaker in doc.cues.iter().filter_map(|c| c.speaker.as_deref()) { + if speaker_ids.contains_key(speaker) { + continue; + } + let mut id = format!("agent{}", speakers.len() + 1); + // Each collision consumes a distinct reserved ID, so this is bounded by ids.len(). + while ids.contains(&id) { + id.push('_'); + } + ids.insert(id.clone()); + speaker_ids.insert(speaker, id.clone()); + speakers.push((speaker, id)); + } o.push_str(" \n"); + for (speaker, id) in speakers { + o.push_str(&format!( + " {}\n", + // XML 1.0 §2.11 normalizes literal CR even with xml:space="preserve". + esc(speaker).replace('\r', " ") + )); + } o.push_str(" \n