diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 60a30d2b..436210ae 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,3 +35,9 @@ jobs: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} + AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} + AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} diff --git a/Cargo.lock b/Cargo.lock index ce0a6c3d..b32d5d25 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1674,7 +1674,7 @@ dependencies = [ [[package]] name = "filmcraft" -version = "0.6.0" +version = "0.7.2" dependencies = [ "arboard", "chrono", @@ -1707,7 +1707,7 @@ dependencies = [ [[package]] name = "filmcraft-aac" -version = "0.6.0" +version = "0.7.2" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -1717,7 +1717,7 @@ dependencies = [ [[package]] name = "filmcraft-ac3" -version = "0.6.0" +version = "0.7.2" dependencies = [ "filmcraft-testkit", "thiserror 2.0.21", @@ -1725,7 +1725,7 @@ dependencies = [ [[package]] name = "filmcraft-apv" -version = "0.6.0" +version = "0.7.2" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -1735,14 +1735,14 @@ dependencies = [ [[package]] name = "filmcraft-audio-dsp" -version = "0.6.0" +version = "0.7.2" dependencies = [ "filmcraft-testkit", ] [[package]] name = "filmcraft-automation" -version = "0.6.0" +version = "0.7.2" dependencies = [ "base64 0.22.1", "filmcraft-engine", @@ -1758,7 +1758,7 @@ dependencies = [ [[package]] name = "filmcraft-av1" -version = "0.6.0" +version = "0.7.2" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -1769,21 +1769,21 @@ dependencies = [ [[package]] name = "filmcraft-avi" -version = "0.6.0" +version = "0.7.2" dependencies = [ "filmcraft-testkit", ] [[package]] name = "filmcraft-bitstream" -version = "0.6.0" +version = "0.7.2" dependencies = [ "proptest", ] [[package]] name = "filmcraft-captions" -version = "0.6.0" +version = "0.7.2" dependencies = [ "filmcraft-color", "filmcraft-project", @@ -1796,11 +1796,11 @@ dependencies = [ [[package]] name = "filmcraft-cfb" -version = "0.6.0" +version = "0.7.2" [[package]] name = "filmcraft-cli" -version = "0.6.0" +version = "0.7.2" dependencies = [ "filmcraft-automation", "filmcraft-codecs", @@ -1822,7 +1822,7 @@ dependencies = [ [[package]] name = "filmcraft-codecs" -version = "0.6.0" +version = "0.7.2" dependencies = [ "filmcraft-aac", "filmcraft-ac3", @@ -1860,7 +1860,7 @@ dependencies = [ [[package]] name = "filmcraft-color" -version = "0.6.0" +version = "0.7.2" dependencies = [ "proptest", "serde", @@ -1868,7 +1868,7 @@ dependencies = [ [[package]] name = "filmcraft-dng" -version = "0.6.0" +version = "0.7.2" dependencies = [ "filmcraft-dng", "rayon", @@ -1877,7 +1877,7 @@ dependencies = [ [[package]] name = "filmcraft-dnx" -version = "0.6.0" +version = "0.7.2" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -1887,7 +1887,7 @@ dependencies = [ [[package]] name = "filmcraft-edit" -version = "0.6.0" +version = "0.7.2" dependencies = [ "filmcraft-geom", "filmcraft-media", @@ -1900,7 +1900,7 @@ dependencies = [ [[package]] name = "filmcraft-engine" -version = "0.6.0" +version = "0.7.2" dependencies = [ "filmcraft-audio-dsp", "filmcraft-captions", @@ -1934,7 +1934,7 @@ dependencies = [ [[package]] name = "filmcraft-export" -version = "0.6.0" +version = "0.7.2" dependencies = [ "filmcraft-aac", "filmcraft-apv", @@ -1965,7 +1965,7 @@ dependencies = [ [[package]] name = "filmcraft-flac" -version = "0.6.0" +version = "0.7.2" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -1974,7 +1974,7 @@ dependencies = [ [[package]] name = "filmcraft-format" -version = "0.6.0" +version = "0.7.2" dependencies = [ "filmcraft-media", "filmcraft-project", @@ -1987,7 +1987,7 @@ dependencies = [ [[package]] name = "filmcraft-frame" -version = "0.6.0" +version = "0.7.2" dependencies = [ "filmcraft-color", "filmcraft-time", @@ -1997,7 +1997,7 @@ dependencies = [ [[package]] name = "filmcraft-geom" -version = "0.6.0" +version = "0.7.2" dependencies = [ "proptest", "serde", @@ -2005,7 +2005,7 @@ dependencies = [ [[package]] name = "filmcraft-golden" -version = "0.6.0" +version = "0.7.2" dependencies = [ "filmcraft-color", "filmcraft-frame", @@ -2022,7 +2022,7 @@ dependencies = [ [[package]] name = "filmcraft-gpu" -version = "0.6.0" +version = "0.7.2" dependencies = [ "filmcraft-color", "filmcraft-export", @@ -2041,7 +2041,7 @@ dependencies = [ [[package]] name = "filmcraft-h264" -version = "0.6.0" +version = "0.7.2" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -2051,7 +2051,7 @@ dependencies = [ [[package]] name = "filmcraft-h264enc" -version = "0.6.0" +version = "0.7.2" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -2061,7 +2061,7 @@ dependencies = [ [[package]] name = "filmcraft-hevc" -version = "0.6.0" +version = "0.7.2" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -2071,7 +2071,7 @@ dependencies = [ [[package]] name = "filmcraft-interchange" -version = "0.6.0" +version = "0.7.2" dependencies = [ "filmcraft-cfb", "filmcraft-color", @@ -2088,7 +2088,7 @@ dependencies = [ [[package]] name = "filmcraft-isobmff" -version = "0.6.0" +version = "0.7.2" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -2099,7 +2099,7 @@ dependencies = [ [[package]] name = "filmcraft-matroska" -version = "0.6.0" +version = "0.7.2" dependencies = [ "filmcraft-testkit", "serde_json", @@ -2107,7 +2107,7 @@ dependencies = [ [[package]] name = "filmcraft-media" -version = "0.6.0" +version = "0.7.2" dependencies = [ "filmcraft-color", "filmcraft-dng", @@ -2123,7 +2123,7 @@ dependencies = [ [[package]] name = "filmcraft-mpa" -version = "0.6.0" +version = "0.7.2" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -2132,7 +2132,7 @@ dependencies = [ [[package]] name = "filmcraft-mpeg2v" -version = "0.6.0" +version = "0.7.2" dependencies = [ "filmcraft-testkit", "rayon", @@ -2141,7 +2141,7 @@ dependencies = [ [[package]] name = "filmcraft-mpegts" -version = "0.6.0" +version = "0.7.2" dependencies = [ "filmcraft-testkit", "thiserror 2.0.21", @@ -2149,28 +2149,28 @@ dependencies = [ [[package]] name = "filmcraft-mxf" -version = "0.6.0" +version = "0.7.2" dependencies = [ "filmcraft-testkit", ] [[package]] name = "filmcraft-ogg" -version = "0.6.0" +version = "0.7.2" dependencies = [ "filmcraft-testkit", ] [[package]] name = "filmcraft-opus" -version = "0.6.0" +version = "0.7.2" dependencies = [ "filmcraft-testkit", ] [[package]] name = "filmcraft-platform" -version = "0.6.0" +version = "0.7.2" dependencies = [ "filmcraft-bitstream", "filmcraft-codecs", @@ -2196,7 +2196,7 @@ dependencies = [ [[package]] name = "filmcraft-project" -version = "0.6.0" +version = "0.7.2" dependencies = [ "filmcraft-color", "filmcraft-geom", @@ -2210,7 +2210,7 @@ dependencies = [ [[package]] name = "filmcraft-prores" -version = "0.6.0" +version = "0.7.2" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -2220,7 +2220,7 @@ dependencies = [ [[package]] name = "filmcraft-render" -version = "0.6.0" +version = "0.7.2" dependencies = [ "filmcraft-audio-dsp", "filmcraft-captions", @@ -2240,7 +2240,7 @@ dependencies = [ [[package]] name = "filmcraft-scopes" -version = "0.6.0" +version = "0.7.2" dependencies = [ "filmcraft-color", "serde", @@ -2249,7 +2249,7 @@ dependencies = [ [[package]] name = "filmcraft-speech" -version = "0.6.0" +version = "0.7.2" dependencies = [ "candle-core", "candle-nn", @@ -2267,14 +2267,14 @@ dependencies = [ [[package]] name = "filmcraft-testkit" -version = "0.6.0" +version = "0.7.2" dependencies = [ "png", ] [[package]] name = "filmcraft-text" -version = "0.6.0" +version = "0.7.2" dependencies = [ "filmcraft-color", "harfrust", @@ -2286,7 +2286,7 @@ dependencies = [ [[package]] name = "filmcraft-time" -version = "0.6.0" +version = "0.7.2" dependencies = [ "proptest", "serde", @@ -2294,7 +2294,7 @@ dependencies = [ [[package]] name = "filmcraft-tts" -version = "0.6.0" +version = "0.7.2" dependencies = [ "candle-core", "candle-nn", @@ -2308,7 +2308,7 @@ dependencies = [ [[package]] name = "filmcraft-tts-text" -version = "0.6.0" +version = "0.7.2" dependencies = [ "serde", "thiserror 2.0.21", @@ -2316,7 +2316,7 @@ dependencies = [ [[package]] name = "filmcraft-ui-egui" -version = "0.6.0" +version = "0.7.2" dependencies = [ "cpu-time", "craft-ui", @@ -2355,14 +2355,14 @@ dependencies = [ [[package]] name = "filmcraft-vp8" -version = "0.6.0" +version = "0.7.2" dependencies = [ "thiserror 2.0.21", ] [[package]] name = "filmcraft-vp9" -version = "0.6.0" +version = "0.7.2" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2371,7 +2371,7 @@ dependencies = [ [[package]] name = "filmcraft-web" -version = "0.6.0" +version = "0.7.2" dependencies = [ "eframe", "egui", @@ -7396,7 +7396,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.6.0" +version = "0.7.2" dependencies = [ "filmcraft-testkit", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index d660c657..340ef75e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/*", "xtask"] [workspace.package] -version = "0.6.0" +version = "0.7.2" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" diff --git a/README.md b/README.md index 6fac25cd..daff93b3 100644 --- a/README.md +++ b/README.md @@ -347,6 +347,13 @@ 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 of one vendor (a laptop with an +integrated and a discrete AMD GPU, for example) can list one GPU twice, once through each installed +version of the Vulkan driver, and a window drawn through the stale one crashes inside the driver +(`amdvlk64.dll`, exit code `0xc0000005`). FilmCraft draws with the newer one and says so in +`%APPDATA%\FilmCraft\Logs\filmcraft.log`; installing one driver package for every GPU removes the +duplicate. With one driver per GPU the adapter is the one wgpu picks, as before. + ### macOS | Build | File | Notes | 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 | diff --git a/apps/filmcraft/src/graphics.rs b/apps/filmcraft/src/graphics.rs index e79ace08..b5cd341b 100644 --- a/apps/filmcraft/src/graphics.rs +++ b/apps/filmcraft/src/graphics.rs @@ -5,6 +5,13 @@ //! `igvk64.dll`, #512 #413; AMD's OpenGL driver `atio6axx.dll`, #477), where no Rust error //! handling runs, so every launch died the same way until the user set `WGPU_BACKEND` by hand. //! +//! # The adapter +//! In the default mode on Windows the adapter is wgpu's own choice, except for a GPU that Vulkan +//! lists through two installed versions of one driver: there the newer one draws, because a +//! window drawn through the stale one dies inside the driver (`amdvlk64.dll`; see +//! `filmcraft_gpu::adapter`). That case is avoided up front instead of left to the fallback +//! below, which would end in DirectX 12 only. +//! //! # When a start counts as failed //! The data folder keeps the mode to start with and the failed starts in a row in it //! ([`STATE_FILE`]). Each start also leaves its own attempt file (`graphics-start/-.json`, @@ -427,6 +434,11 @@ fn apply_for(options: &mut NativeOptions, mode: Mode, backend_override: Option create.instance_descriptor.backends = backend_override.unwrap_or(Backends::PRIMARY), + Mode::Default if windows => { + create.instance_descriptor.backends = backend_override.unwrap_or(Backends::PRIMARY); + // wgpu's own choice of adapter, except for a GPU that Windows lists through two + // versions of one driver: the first entry can be a stale one that crashes inside the + // driver (`filmcraft_gpu::adapter`). A selector replaces `power_preference` + // (`WGPU_POWER_PREF`), so it takes that along. + let power = create.power_preference; + create.native_adapter_selector = + Some(Arc::new(move |adapters: &[wgpu::Adapter], surface: Option<&wgpu::Surface<'_>>| filmcraft_gpu::adapter::pick(adapters, surface, power))); + } Mode::Default => {} // Without Vulkan, whose driver is what crashes; GPU effects may fall back to the CPU. Mode::Dx12 => create.instance_descriptor.backends = Backends::DX12, @@ -484,6 +505,22 @@ mod tests { (create.instance_descriptor.backends, create.native_adapter_selector.is_some()) } + #[test] + fn no_mode_validates_indirect_calls() { + if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_some() { + return; + } + for mode in [Mode::Default, Mode::Dx12, Mode::Software] { + let mut options = NativeOptions::default(); + apply_for(&mut options, mode, None, false); + let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else { + panic!("default native options must create a wgpu instance"); + }; + // Intel Metal can't compile the validation's compute shader, and the device is lost. + assert!(!create.instance_descriptor.flags.contains(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL), "{mode:?}"); + } + } + #[test] fn windows_default_leaves_out_gl() { // eframe's own default (PRIMARY | GL) would initialize OpenGL as well. @@ -495,7 +532,19 @@ mod tests { assert_eq!(backends, Backends::PRIMARY); assert!(!backends.contains(Backends::GL)); assert!(backends.contains(Backends::DX12) && backends.contains(Backends::VULKAN)); - assert!(!selector); + // and chooses the adapter itself: a GPU listed through two versions of one driver + assert!(selector); + } + + #[test] + fn only_the_windows_default_and_software_modes_choose_the_adapter() { + for backend_override in [None, Some(Backends::VULKAN), Some(Backends::DX12)] { + assert!(configured(Mode::Default, backend_override, true).1); + // elsewhere, and in DirectX 12 only mode, the adapter is wgpu's own choice + assert!(!configured(Mode::Default, backend_override, false).1); + assert!(!configured(Mode::Dx12, backend_override, true).1); + } + assert!(configured(Mode::Software, None, true).1 && configured(Mode::Software, None, false).1); } #[test] 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:?}"); + } } diff --git a/contributors/people.toml b/contributors/people.toml index 5a64a93d..7f6a1c2a 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -81,3 +81,10 @@ sync_github_name = false [people.n-delic] display_name = "n-delic" sync_github_name = false + +[people.Silvertree2010] +display_name = "Netter" +sync_github_name = false + +[people.webdevtodayjason] +display_name = "Jason Brashear" 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/automation/src/bridge.rs b/crates/automation/src/bridge.rs index 5fdc2f0d..f51db9ad 100644 --- a/crates/automation/src/bridge.rs +++ b/crates/automation/src/bridge.rs @@ -1,4 +1,5 @@ -//! Client for the desktop app's JSON-lines control protocol. One connection, reconnect on failure. +//! Client for the desktop app's JSON-lines control protocol. A connection per request in flight, +//! reused once its reply is in; reconnect on failure. use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Duration; @@ -12,12 +13,17 @@ use crate::AutomationError; /// How often a blocking export's app job is polled. const JOB_POLL: Duration = Duration::from_millis(250); +/// Idle connections kept for reuse; the app serves at most 16 at once. +const MAX_IDLE: usize = 4; type Conn = (BufReader, tokio::net::tcp::OwnedWriteHalf); pub struct BridgeClient { addr: String, - conn: Mutex>, + /// Connections with no request in flight. A request takes one (or opens one) for its whole + /// round trip and puts it back only once the reply is read, so a slow or abandoned request + /// never holds up the others or leaves its late reply for the next one to read (#1538). + idle: Mutex>, next_id: AtomicU64, } @@ -29,7 +35,7 @@ impl BridgeClient { if !matches!(host, "127.0.0.1" | "localhost" | "[::1]" | "::1") { return Err(AutomationError::BadRequest(format!("bridge address must be loopback, got `{addr}`"))); } - Ok(Self { addr, conn: Mutex::new(None), next_id: AtomicU64::new(1) }) + Ok(Self { addr, idle: Mutex::new(Vec::new()), next_id: AtomicU64::new(1) }) } /// Run engine command `id` in the app. A blocking export (`file.exportMedia` with @@ -71,20 +77,14 @@ impl BridgeClient { /// Call a control method; returns `result` or the app's error. pub async fn call(&self, method: &str, params: Value) -> Result { - let mut guard = self.conn.lock().await; for attempt in 0..2 { - if guard.is_none() { - let s = tokio::time::timeout(Duration::from_secs(5), TcpStream::connect(&self.addr)) - .await - .map_err(|_| AutomationError::Bridge(format!("timed out connecting to {}", self.addr)))? - .map_err(|e| AutomationError::Bridge(format!("cannot connect to {} ({e}); start the app with `filmcraft --control `", self.addr)))?; - let (r, w) = s.into_split(); - *guard = Some((BufReader::new(r), w)); - } - let id = self.next_id.fetch_add(1, Ordering::Relaxed); - let Some(conn) = guard.as_mut() else { - return Err(AutomationError::Bridge(format!("not connected to {}", self.addr))); + // A retry gets a fresh connection: the failed one may have been closed by an app restart. + let idle = if attempt == 0 { self.idle.lock().await.pop() } else { None }; + let mut conn = match idle { + Some(c) => c, + None => self.connect().await?, }; + let id = self.next_id.fetch_add(1, Ordering::Relaxed); let line = format!("{}\n", json!({"id": id, "method": method, "params": params})); let res: Result = async { conn.1.write_all(line.as_bytes()).await.map_err(|e| AutomationError::Bridge(e.to_string()))?; @@ -98,21 +98,32 @@ impl BridgeClient { .await; match res { Ok(v) => { + let mut idle = self.idle.lock().await; + if idle.len() < MAX_IDLE { + idle.push(conn); + } + drop(idle); return if v.get("ok").and_then(Value::as_bool) == Some(true) { Ok(v.get("result").cloned().unwrap_or(Value::Null)) } else { Err(AutomationError::App(v.get("error").and_then(Value::as_str).unwrap_or("error").to_string())) }; } - Err(e) if attempt == 0 => { - *guard = None; - let _ = e; - } + Err(_) if attempt == 0 => {} Err(e) => return Err(e), } } Err(AutomationError::Bridge("unreachable".into())) } + + async fn connect(&self) -> Result { + let s = tokio::time::timeout(Duration::from_secs(5), TcpStream::connect(&self.addr)) + .await + .map_err(|_| AutomationError::Bridge(format!("timed out connecting to {}", self.addr)))? + .map_err(|e| AutomationError::Bridge(format!("cannot connect to {} ({e}); start the app with `filmcraft --control `", self.addr)))?; + let (r, w) = s.into_split(); + Ok((BufReader::new(r), w)) + } } #[cfg(test)] @@ -195,6 +206,71 @@ mod tests { assert!(e.to_string().contains("export failed: encode: disk full"), "{e}"); } + /// A stand-in for the app that serves any number of connections and answers each request on + /// its own, as the app does: `slow` after `slow_reply` (never when `None`), anything else at + /// once with its command id. The ids of the requests it receives are sent to the returned channel. + async fn slow_app(slow_reply: Option) -> (String, tokio::sync::mpsc::UnboundedReceiver) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let (seen, rx) = tokio::sync::mpsc::unbounded_channel(); + tokio::spawn(async move { + while let Ok((s, _)) = listener.accept().await { + let seen = seen.clone(); + tokio::spawn(async move { + let (r, w) = s.into_split(); + let w = Arc::new(Mutex::new(w)); + let mut lines = BufReader::new(r).lines(); + while let Ok(Some(line)) = lines.next_line().await { + let req: Value = serde_json::from_str(&line).unwrap(); + let cmd = req["params"]["command"].as_str().unwrap_or_default().to_string(); + let _ = seen.send(cmd.clone()); + let delay = if cmd == "slow" { slow_reply } else { Some(Duration::ZERO) }; + let w = w.clone(); + tokio::spawn(async move { + let Some(d) = delay else { return }; + tokio::time::sleep(d).await; + let line = format!("{}\n", json!({"id": req["id"], "ok": true, "result": cmd})); + let _ = w.lock().await.write_all(line.as_bytes()).await; + }); + } + }); + } + }); + (addr, rx) + } + + /// A command the app is slow to answer holds up only itself: a later call is answered at once + /// instead of queueing behind it, and behind every call queued after it (#1538). + #[tokio::test] + async fn a_slow_command_does_not_block_later_calls() { + let (addr, mut seen) = slow_app(None).await; + let b = Arc::new(BridgeClient::new(addr).unwrap()); + let slow = tokio::spawn({ + let b = b.clone(); + async move { b.execute("slow", json!({})).await } + }); + assert_eq!(seen.recv().await.as_deref(), Some("slow")); + let fast = tokio::time::timeout(Duration::from_secs(5), b.execute("fast", json!({}))).await; + assert_eq!(fast.expect("the later call waited on the slow one").unwrap(), json!("fast")); + slow.abort(); + } + + /// A call given up while it waits leaves no late reply on a connection for the next call to read + /// as its own (#1538). + #[tokio::test] + async fn an_abandoned_call_leaves_no_late_reply_behind() { + let (addr, mut seen) = slow_app(Some(Duration::from_millis(100))).await; + let b = Arc::new(BridgeClient::new(addr).unwrap()); + let slow = tokio::spawn({ + let b = b.clone(); + async move { b.execute("slow", json!({})).await } + }); + assert_eq!(seen.recv().await.as_deref(), Some("slow")); + slow.abort(); + tokio::time::sleep(Duration::from_millis(300)).await; + assert_eq!(b.execute("fast", json!({})).await.unwrap(), json!("fast")); + } + /// Everything else is forwarded unchanged. #[tokio::test] async fn other_commands_are_forwarded() { 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(); +} 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]; 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..9eadf0e7 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