diff --git a/Cargo.lock b/Cargo.lock index 5529201a..77d37854 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1687,6 +1687,7 @@ dependencies = [ "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "open", + "pollster 0.4.0", "rfd", "serde_json", "sys-locale", diff --git a/apps/filmcraft/Cargo.toml b/apps/filmcraft/Cargo.toml index 85175489..3ae35e6c 100644 --- a/apps/filmcraft/Cargo.toml +++ b/apps/filmcraft/Cargo.toml @@ -41,6 +41,8 @@ open = "5" [dev-dependencies] glob = "0.3" +# graphics.rs: enumerate the real adapters in a test. +pollster = "0.4" [target.'cfg(target_os = "macos")'.dependencies] muda = "0.21" diff --git a/apps/filmcraft/src/args.rs b/apps/filmcraft/src/args.rs index 6460a968..c923112c 100644 --- a/apps/filmcraft/src/args.rs +++ b/apps/filmcraft/src/args.rs @@ -26,7 +26,7 @@ pub enum Cli { } /// Options of a normal start. -#[derive(Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq)] pub struct Launch { pub control_port: Option, /// A project and / or media files to open. diff --git a/apps/filmcraft/src/graphics.rs b/apps/filmcraft/src/graphics.rs index b2be99cb..511ec982 100644 --- a/apps/filmcraft/src/graphics.rs +++ b/apps/filmcraft/src/graphics.rs @@ -1,33 +1,237 @@ -//! Windows graphics instance configuration, applied before wgpu can initialize any driver. +//! Graphics start-up: the wgpu backends and adapter the window is created with, and the fallback +//! when a start fails (docs/gaps.md G4, #820). +//! +//! A graphics driver can crash inside wgpu instance or device creation (Intel's Vulkan driver +//! `igvk64.dll`, #512 #413; AMD's OpenGL driver `atio6axx.dll`, #477), where no Rust error +//! handling runs, so every launch died the same way until the user set `WGPU_BACKEND` by hand. +//! Each start now leaves a marker in the data folder ([`STATE_FILE`]) that is cleared once the +//! window has painted. A marker still pending at the next launch means that start died: the launch +//! moves on to the next [`Mode`] of the platform's [`LADDER`] and keeps it. A start-up error that +//! wgpu does report (no adapter, device creation failed) moves on within the same process. An +//! explicit `WGPU_BACKEND` leaves the marker alone: the user is in charge. -use eframe::{NativeOptions, egui_wgpu::WgpuSetup, wgpu::Backends}; +use std::io::Read as _; +use std::path::{Path, PathBuf}; +use std::sync::Arc; -/// Restrict the wgpu instance to the primary backends (Vulkan, DirectX 12, Metal), i.e. leave -/// OpenGL out, unless `backend_override` (normally `Backends::from_env()`, i.e. `WGPU_BACKEND`) -/// names other backends. -pub fn configure(options: &mut NativeOptions, backend_override: Option) { - if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup { - // eframe's default includes GL. Creating that backend can crash inside AMD's - // atio6axx.dll before adapter selection or any Rust error handling runs, so the - // window never appears. Choosing an adapter afterwards is too late: only leaving GL - // out of the instance avoids it. Keep Vulkan and DX12 both: forcing DX12 alone sends - // shaders through FXC (which rejects fx.wgsl, silently falling back to the CPU) and - // leaves machines without DX12 with no backend at all. - create.instance_descriptor.backends = backend_override.unwrap_or(Backends::PRIMARY); +use eframe::{ + NativeOptions, + egui_wgpu::WgpuSetup, + wgpu::{self, Backends}, +}; + +/// The start marker in the data folder: `{"mode": "", "pending": }`. Delete it to +/// start with the default graphics mode again. +pub const STATE_FILE: &str = "graphics-start.json"; + +/// How the window's graphics device is created. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Mode { + /// The platform default: Vulkan or DirectX 12 on Windows (no OpenGL, see [`apply`]), Metal on + /// macOS, Vulkan or OpenGL elsewhere. + Default, + /// DirectX 12 only (Windows), for Vulkan drivers that crash at start-up. + Dx12, + /// A software adapter (WARP on Windows, lavapipe / llvmpipe elsewhere); the preview then + /// composites on the CPU (`FilmcraftApp::set_wgpu`). + Software, +} + +impl Mode { + pub fn name(self) -> &'static str { + match self { + Mode::Default => "default", + Mode::Dx12 => "dx12", + Mode::Software => "software", + } } + + fn parse(name: &str) -> Option { + [Mode::Default, Mode::Dx12, Mode::Software].into_iter().find(|m| m.name() == name) + } +} + +/// The modes tried in order on this platform. macOS has only Metal, and no software adapter. +pub const LADDER: &[Mode] = if cfg!(target_os = "windows") { + &[Mode::Default, Mode::Dx12, Mode::Software] +} else if cfg!(target_os = "macos") { + &[Mode::Default] +} else { + &[Mode::Default, Mode::Software] +}; + +/// The mode after `mode` in `ladder`, if any. +fn next_in(ladder: &[Mode], mode: Mode) -> Option { + let i = ladder.iter().position(|m| *m == mode)?; + ladder.get(i.checked_add(1)?).copied() +} + +/// This launch's mode from the saved marker (`(mode, pending)`; `None` when there is none or it +/// is unreadable), and whether it moved on because the last start died. +fn launch_mode(saved: Option<(Mode, bool)>, ladder: &[Mode]) -> (Mode, bool) { + let first = ladder.first().copied().unwrap_or(Mode::Default); + match saved { + // A mode this platform does not use (a data folder copied from another OS): start over. + Some((mode, _)) if !ladder.contains(&mode) => (first, false), + Some((mode, true)) => match next_in(ladder, mode) { + Some(next) => (next, true), + // Nothing left to try: the last mode is still the best chance. + None => (mode, false), + }, + Some((mode, false)) => (mode, false), + None => (first, false), + } +} + +/// `(mode, pending)` from the marker's text; `None` for anything else. +fn parse_state(text: &str) -> Option<(Mode, bool)> { + let v: serde_json::Value = serde_json::from_str(text).ok()?; + Some((Mode::parse(v.get("mode")?.as_str()?)?, v.get("pending")?.as_bool()?)) +} + +fn read_state(path: &Path) -> Option<(Mode, bool)> { + // The marker is a few bytes; anything longer is not ours. + let mut text = String::new(); + std::fs::File::open(path).ok()?.take(4096).read_to_string(&mut text).ok()?; + parse_state(&text) +} + +fn write_state(path: &Path, mode: Mode, pending: bool) -> std::io::Result<()> { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + let text = serde_json::json!({ "mode": mode.name(), "pending": pending }).to_string(); + // Written beside and renamed, so a start that dies mid-write leaves the old marker whole. + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, text)?; + std::fs::rename(&tmp, path) +} + +/// This launch's graphics mode and its start marker. +#[derive(Clone, Debug)] +pub struct GraphicsStart { + /// The marker; `None` without a data folder or with `WGPU_BACKEND` set. + path: Option, + mode: Mode, +} + +impl GraphicsStart { + /// Pick this launch's mode from the marker in `data_dir` and mark the start pending. + /// `explicit_backend` (`WGPU_BACKEND` set) starts in [`Mode::Default`] and leaves the marker + /// alone. + pub fn begin(data_dir: Option<&Path>, explicit_backend: bool) -> Self { + let path = if explicit_backend { None } else { data_dir.map(|d| d.join(STATE_FILE)) }; + let saved = path.as_deref().and_then(read_state); + let (mode, moved_on) = launch_mode(saved, LADDER); + let file = path.as_deref().map(|p| p.display().to_string()).unwrap_or_default(); + if moved_on { + let failed = saved.map_or("default", |(m, _)| m.name()); + log::warn!( + "the last start did not get its window up with graphics mode {failed}; starting with {} (delete {file} to try the default again)", + mode.name() + ); + } else if mode != Mode::Default { + log::info!("graphics mode {} after an earlier failed start (delete {file} to try the default again)", mode.name()); + } + let start = Self { path, mode }; + start.save(true); + start + } + + pub fn mode(&self) -> Mode { + self.mode + } + + /// Move on to the next mode after a start-up error wgpu reported; `false` when none is left. + pub fn step_down(&mut self) -> bool { + let Some(next) = next_in(LADDER, self.mode) else { + return false; + }; + log::warn!("graphics mode {} failed to start; trying {}", self.mode.name(), next.name()); + self.mode = next; + self.save(true); + true + } + + /// The window is up: keep this mode for the next launches. + pub fn confirm(&self) { + self.save(false); + } + + fn save(&self, pending: bool) { + if let Some(path) = &self.path + && let Err(e) = write_state(path, self.mode, pending) + { + log::warn!("can't write {}: {e}", path.display()); + } + } +} + +/// Configure `options` for `mode`, before eframe creates the wgpu instance. `backend_override` +/// is normally `Backends::from_env()`, i.e. `WGPU_BACKEND`. +pub fn apply(options: &mut NativeOptions, mode: Mode, backend_override: Option) { + apply_for(options, mode, backend_override, cfg!(target_os = "windows")); +} + +fn apply_for(options: &mut NativeOptions, mode: Mode, backend_override: Option, windows: bool) { + let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup else { + return; + }; + match mode { + // eframe's default (`WGPU_BACKEND`, else PRIMARY | GL), except on Windows: there it + // includes GL. Creating that backend can crash inside AMD's atio6axx.dll before adapter + // selection or any Rust error handling runs, so the window never appears. Choosing an + // adapter afterwards is too late: only leaving GL out of the instance avoids it. Keep + // Vulkan and DX12 both: forcing DX12 alone sends shaders through FXC (which rejects + // fx.wgsl, silently falling back to the CPU) and leaves machines without DX12 with no + // backend at all. + Mode::Default if windows => create.instance_descriptor.backends = backend_override.unwrap_or(Backends::PRIMARY), + Mode::Default => {} + // Without Vulkan, whose driver is what crashes; GPU effects may fall back to the CPU. + Mode::Dx12 => create.instance_descriptor.backends = Backends::DX12, + Mode::Software => { + create.instance_descriptor.backends = software_backends(windows); + create.native_adapter_selector = Some(Arc::new(pick_software)); + } + } +} + +/// The backends a software adapter is looked for in: WARP is a DX12 adapter, and GL stays out on +/// Windows for the reason in [`apply_for`]. +fn software_backends(windows: bool) -> Backends { + if windows { Backends::DX12 } else { Backends::VULKAN | Backends::GL } +} + +/// The first software adapter that can draw to the window's surface. +fn pick_software(adapters: &[wgpu::Adapter], surface: Option<&wgpu::Surface<'_>>) -> Result { + adapters + .iter() + .find(|a| filmcraft_ui_egui::software_rasterizer(&a.get_info()).is_some() && surface.is_none_or(|s| a.is_surface_supported(s))) + .cloned() + .ok_or_else(|| format!("no software graphics adapter (WARP, lavapipe, llvmpipe) among {} adapters", adapters.len())) +} + +/// Whether `err` from `eframe::run_native` is a graphics start-up failure that another mode may +/// avoid (as opposed to a display, event-loop or app error). +pub fn is_graphics_failure(err: &eframe::Error) -> bool { + matches!(err, eframe::Error::Wgpu(_)) } #[cfg(test)] mod tests { use super::*; - fn configured_backends(backend_override: Option) -> Backends { + const WINDOWS: &[Mode] = &[Mode::Default, Mode::Dx12, Mode::Software]; + const LINUX: &[Mode] = &[Mode::Default, Mode::Software]; + const MACOS: &[Mode] = &[Mode::Default]; + + fn configured(mode: Mode, backend_override: Option, windows: bool) -> (Backends, bool) { let mut options = NativeOptions::default(); - configure(&mut options, backend_override); + apply_for(&mut options, mode, backend_override, windows); let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else { panic!("default native options must create a wgpu instance"); }; - create.instance_descriptor.backends + (create.instance_descriptor.backends, create.native_adapter_selector.is_some()) } #[test] @@ -37,16 +241,159 @@ mod tests { panic!("default native options must create a wgpu instance"); }; assert!(eframe_default.instance_descriptor.backends.contains(Backends::GL)); - let configured = configured_backends(None); - assert_eq!(configured, Backends::PRIMARY); - assert!(!configured.contains(Backends::GL)); - assert!(configured.contains(Backends::DX12) && configured.contains(Backends::VULKAN)); + let (backends, selector) = configured(Mode::Default, None, true); + assert_eq!(backends, Backends::PRIMARY); + assert!(!backends.contains(Backends::GL)); + assert!(backends.contains(Backends::DX12) && backends.contains(Backends::VULKAN)); + assert!(!selector); } #[test] fn explicit_backend_override_is_preserved() { for backends in [Backends::VULKAN, Backends::GL, Backends::DX12, Backends::VULKAN | Backends::DX12, Backends::empty()] { - assert_eq!(configured_backends(Some(backends)), backends); + assert_eq!(configured(Mode::Default, Some(backends), true).0, backends); } } + + #[test] + fn fallback_modes_leave_out_the_crashing_backends() { + // DX12 only: Intel's and AMD's Vulkan drivers are what crashed (#512, #413). + assert_eq!(configured(Mode::Dx12, None, true), (Backends::DX12, false)); + // Software on Windows is WARP (DX12), never GL (atio6axx.dll, #477). + assert_eq!(configured(Mode::Software, None, true), (Backends::DX12, true)); + assert_eq!(configured(Mode::Software, None, false), (Backends::VULKAN | Backends::GL, true)); + // Off Windows the default is eframe's own. + let WgpuSetup::CreateNew(eframe_default) = NativeOptions::default().wgpu_options.wgpu_setup else { + panic!("default native options must create a wgpu instance"); + }; + assert_eq!(configured(Mode::Default, None, false), (eframe_default.instance_descriptor.backends, false)); + } + + #[test] + fn a_start_that_died_moves_on_to_the_next_mode_and_keeps_it() { + // first launch + assert_eq!(launch_mode(None, WINDOWS), (Mode::Default, false)); + // the default start died in the driver: DX12 next, then software + assert_eq!(launch_mode(Some((Mode::Default, true)), WINDOWS), (Mode::Dx12, true)); + assert_eq!(launch_mode(Some((Mode::Dx12, true)), WINDOWS), (Mode::Software, true)); + assert_eq!(launch_mode(Some((Mode::Default, true)), LINUX), (Mode::Software, true)); + // a confirmed mode is kept + assert_eq!(launch_mode(Some((Mode::Dx12, false)), WINDOWS), (Mode::Dx12, false)); + assert_eq!(launch_mode(Some((Mode::Default, false)), WINDOWS), (Mode::Default, false)); + // nothing left to try: stay + assert_eq!(launch_mode(Some((Mode::Software, true)), WINDOWS), (Mode::Software, false)); + assert_eq!(launch_mode(Some((Mode::Default, true)), MACOS), (Mode::Default, false)); + // a mode this platform has not: start over + assert_eq!(launch_mode(Some((Mode::Dx12, false)), LINUX), (Mode::Default, false)); + assert_eq!(launch_mode(Some((Mode::Software, true)), MACOS), (Mode::Default, false)); + assert_eq!(launch_mode(None, &[]), (Mode::Default, false)); + } + + #[test] + fn the_marker_round_trips_and_rejects_anything_else() { + for mode in WINDOWS { + for pending in [false, true] { + let text = serde_json::json!({ "mode": mode.name(), "pending": pending }).to_string(); + assert_eq!(parse_state(&text), Some((*mode, pending))); + } + } + for bad in [ + "", + "{", + "null", + "[]", + r#"{"mode":"vulkan","pending":true}"#, + r#"{"mode":"dx12"}"#, + r#"{"mode":1,"pending":true}"#, + r#"{"mode":"dx12","pending":"yes"}"#, + ] { + assert_eq!(parse_state(bad), None, "{bad}"); + } + } + + fn temp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("filmcraft-graphics-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + dir + } + + #[test] + fn begin_marks_the_start_pending_and_confirm_clears_it() { + let dir = temp_dir("marker"); + let file = dir.join(STATE_FILE); + let start = GraphicsStart::begin(Some(&dir), false); + assert_eq!(start.mode(), Mode::Default); + assert_eq!(read_state(&file), Some((Mode::Default, true))); + start.confirm(); + assert_eq!(read_state(&file), Some((Mode::Default, false))); + // a confirmed start is kept on the next launch + assert_eq!(GraphicsStart::begin(Some(&dir), false).mode(), Mode::Default); + // ... and one that never confirmed moves on (Windows and Linux; macOS has nowhere to go) + let next = GraphicsStart::begin(Some(&dir), false); + assert_eq!(next.mode(), LADDER.get(1).copied().unwrap_or(Mode::Default)); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_corrupt_or_huge_marker_starts_with_the_default() { + let dir = temp_dir("corrupt"); + let file = dir.join(STATE_FILE); + let _ = std::fs::create_dir_all(&dir); + for text in ["garbage".to_string(), format!(r#"{{"mode":"dx12","pending":true,"pad":"{}"}}"#, "x".repeat(10_000))] { + let _ = std::fs::write(&file, text); + let start = GraphicsStart::begin(Some(&dir), false); + assert_eq!(start.mode(), Mode::Default); + // and it is replaced with a well-formed pending marker + assert_eq!(read_state(&file), Some((Mode::Default, true))); + } + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn an_explicit_backend_leaves_the_marker_alone() { + let dir = temp_dir("explicit"); + let file = dir.join(STATE_FILE); + let _ = std::fs::create_dir_all(&dir); + let _ = write_state(&file, Mode::Software, true); + let mut start = GraphicsStart::begin(Some(&dir), true); + assert_eq!(start.mode(), Mode::Default); + start.confirm(); + let _ = start.step_down(); + assert_eq!(read_state(&file), Some((Mode::Software, true))); + // no data folder: nothing is written, the default still starts + assert_eq!(GraphicsStart::begin(None, false).mode(), Mode::Default); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn step_down_walks_the_ladder_and_records_each_attempt() { + let dir = temp_dir("step"); + let file = dir.join(STATE_FILE); + let mut start = GraphicsStart::begin(Some(&dir), false); + for expected in LADDER.iter().skip(1) { + assert!(start.step_down()); + assert_eq!(start.mode(), *expected); + assert_eq!(read_state(&file), Some((*expected, true))); + } + assert!(!start.step_down(), "nothing after the last mode"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_software_selector_only_picks_a_software_adapter() { + if wgpu::Instance::enabled_backend_features().is_empty() { + return; + } + // only the backends software mode uses: no driver this test loads is one the app avoids + let backends = software_backends(cfg!(target_os = "windows")); + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { backends, ..wgpu::InstanceDescriptor::new_without_display_handle() }); + let adapters = pollster::block_on(instance.enumerate_adapters(backends)); + match pick_software(&adapters, None) { + Ok(adapter) => assert!(filmcraft_ui_egui::software_rasterizer(&adapter.get_info()).is_some()), + Err(why) => { + assert!(adapters.iter().all(|a| filmcraft_ui_egui::software_rasterizer(&a.get_info()).is_none()), "{why}"); + } + } + assert!(pick_software(&[], None).is_err()); + } } diff --git a/apps/filmcraft/src/main.rs b/apps/filmcraft/src/main.rs index 591bc00f..86bac782 100644 --- a/apps/filmcraft/src/main.rs +++ b/apps/filmcraft/src/main.rs @@ -31,7 +31,6 @@ mod control_server; #[cfg(target_os = "linux")] mod dev_icon; mod file_filters; -#[cfg(any(target_os = "windows", test))] mod graphics; mod logging; #[cfg(target_os = "macos")] @@ -44,6 +43,8 @@ use filmcraft_engine::Session; use filmcraft_engine::autosave::{AutosaveConfig, default_data_dir}; use filmcraft_ui_egui::FilmcraftApp; use serde_json::json; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; /// Reverse-DNS app id: Linux `.desktop` file, hicolor icon name and Wayland app id. const APP_ID: &str = "ai.storyteller.filmcraft"; @@ -80,7 +81,7 @@ fn main() -> eframe::Result { let logger = logging::install(); // lossy, so a value that is not Unicode is reported like any other bad port instead of ignored let env_port = std::env::var_os("FILMCRAFT_CONTROL_PORT").map(|p| p.to_string_lossy().into_owned()); - let Launch { control_port, files, demo, startup_flag, recover, data_dir } = match args::parse(env_port, std::env::args().skip(1)) { + let launch = match args::parse(env_port, std::env::args().skip(1)) { Ok(Cli::Run(l)) => l, Ok(Cli::Help) => { tell(args::USAGE, false); @@ -95,7 +96,8 @@ fn main() -> eframe::Result { std::process::exit(2); } }; - let log_dir = data_dir.clone().or_else(default_data_dir).map(|d| d.join(logging::LOG_DIR)); + let data_root = launch.data_dir.clone().or_else(default_data_dir); + let log_dir = data_root.as_ref().map(|d| d.join(logging::LOG_DIR)); log::info!("FilmCraft {} ({} {})", env!("CARGO_PKG_VERSION"), std::env::consts::OS, std::env::consts::ARCH); // The log file lives next to the crash logs; opened after the arguments, so `--help`, // `--version` and usage errors leave no file behind. Records logged until now go in first. @@ -118,165 +120,35 @@ fn main() -> eframe::Result { // OS hardware video decoders (VideoToolbox on macOS) in front of our own; Settings ▸ Playback ▸ // Hardware decoding switches them off. Unsupported streams and failures use our decoders. register_hardware_decoders(); - register_gpu_frame_renderer(); - let options = eframe::NativeOptions { - viewport: egui::ViewportBuilder::default() - .with_title("FilmCraft") - .with_inner_size([1600.0, 980.0]) - .with_min_inner_size([900.0, 560.0]) - .with_drag_and_drop(true) - .with_fullsize_content_view(true) - .with_titlebar_shown(false) - .with_title_shown(false) - // Started for an agent (`--control`): open without taking the user's keyboard focus. - .with_active(control_port.is_none()) - .with_icon(app_icon()) - // Wayland matches the window to `packaging/linux/ai.storyteller.filmcraft.desktop`. - .with_app_id(APP_ID), - event_loop_builder: agent_event_loop(control_port.is_some()), - ..Default::default() + // Which graphics backends and adapter the window starts with: the mode after the last one + // if the previous start died before its window painted (a driver crash), see graphics.rs. + let backend_override = eframe::wgpu::Backends::from_env(); + let mut graphics = graphics::GraphicsStart::begin(data_root.as_deref(), backend_override.is_some()); + // The GPU export renderer creates its own wgpu instance with every backend; after a start + // fell back, that would load the driver that failed. Exports then composite on the CPU. + if graphics.mode() == graphics::Mode::Default { + register_gpu_frame_renderer(); + } + let started = loop { + let mut options = native_options(launch.control_port.is_some()); + // Before eframe creates the wgpu instance (e.g. no OpenGL on Windows, see graphics.rs). + graphics::apply(&mut options, graphics.mode(), backend_override); + let app_created = Arc::new(AtomicBool::new(false)); + let result = eframe::run_native("FilmCraft", options, app_creator(launch.clone(), graphics.clone(), Arc::clone(&app_created))); + let app_created = app_created.load(Ordering::Relaxed); + match &result { + // wgpu reported the failure (no adapter, device creation failed) before the app + // existed: try the next mode in this process. + Err(e) if !app_created && graphics::is_graphics_failure(e) && graphics.step_down() => { + log::warn!("graphics start failed: {e}"); + continue; + } + // Closed before the second frame: the window still came up. + Ok(()) if app_created => graphics.confirm(), + _ => {} + } + break result; }; - // Before eframe creates the wgpu instance: leave OpenGL out on Windows (see graphics.rs). - #[cfg(target_os = "windows")] - let options = { - let mut options = options; - graphics::configure(&mut options, eframe::wgpu::Backends::from_env()); - options - }; - let started = eframe::run_native( - "FilmCraft", - options, - Box::new(move |cc| { - let mut session = Session::default(); - if let Some(dir) = data_dir.clone().or_else(default_data_dir) { - let mut cfg = AutosaveConfig::new(dir); - cfg.local_offset = local_offset; - if let Err(e) = session.start_autosave(cfg) { - log::warn!("auto-save and crash recovery unavailable: {e}"); - } - } - // voice-over recording reads the microphone through cpal - session.voiceover.input = Some(Box::new(audio_in::CpalIn::new(&session.prefs.audio_hardware.device_class))); - let project = files.iter().find(|f| f.ends_with(".fcproj")).cloned(); - if let Some(p) = project { - if let Err(e) = session.execute("file.open", json!({"path": p})) { - log::error!("{e}"); - } - } else if !startup_flag && session.prefs.general.at_startup == "openMostRecent" { - // Settings ▸ General ▸ At Startup ▸ Open Most Recent - let recent = session.prefs.general.recent_projects.iter().find(|p| std::path::Path::new(p).exists()).cloned(); - match recent { - Some(p) => { - if let Err(e) = session.execute("file.open", json!({"path": p})) { - log::error!("{e}"); - } - } - None => { - let _ = session.execute("file.openDemoProject", json!({})); - } - } - } else if !startup_flag && session.prefs.general.at_startup == "emptyProject" { - } else if demo { - let _ = session.execute("file.openDemoProject", json!({})); - } - let media: Vec = files.iter().filter(|f| !f.ends_with(".fcproj")).cloned().collect(); - if !media.is_empty() { - let _ = session.execute("file.import", json!({"paths": media})); - } - if recover == Some(true) && !session.recovery_candidates().is_empty() { - let id = session.recovery_candidates()[0].id.clone(); - match session.execute("file.recover", json!({"id": id})) { - Ok(r) => log::info!("recovered {r}"), - Err(e) => log::error!("recovery failed: {e}"), - } - } - let mut app = FilmcraftApp::new(session); - if recover == Some(false) { - app.dialog = None; - } - app.integrated_titlebar = cfg!(target_os = "macos"); - if let Some(rs) = cc.wgpu_render_state.clone() - && std::env::var_os("FILMCRAFT_CPU_COMPOSITE").is_none() - { - app.set_wgpu(rs); - } - // Keep device selection available even when the default device is unavailable. - // Settings ▸ Audio Hardware is applied on the first frame (`apply_prefs`). - app.audio = Some(Box::new(audio::CpalOut::new())); - app.hooks.pick_files = Some(Box::new(|exts: &[&str]| { - rfd::FileDialog::new() - .add_filter(filmcraft_ui_egui::i18n::t("Media"), &file_filters::extensions(exts)) - .pick_files() - .unwrap_or_default() - .into_iter() - .map(|p| p.to_string_lossy().to_string()) - .collect() - })); - // Link Media ▸ Locate…, Attach Proxies, Reconnect Full Resolution: one path, not imported. - app.hooks.pick_file_for_relink = Some(Box::new(|exts: &[&str], _hint| { - rfd::FileDialog::new() - .add_filter(filmcraft_ui_egui::i18n::t("Media"), &file_filters::extensions(exts)) - .pick_file() - .map(|p| p.to_string_lossy().to_string()) - })); - app.hooks.pick_save = Some(Box::new(|name: &str| { - rfd::FileDialog::new() - .add_filter(filmcraft_ui_egui::i18n::t("FilmCraft Project"), &file_filters::extensions(&["fcproj"])) - .set_file_name(name) - .save_file() - .map(|p| p.to_string_lossy().to_string()) - })); - app.hooks.pick_save_as = Some(Box::new(|filter: &str, exts: &[&str], name: &str| { - rfd::FileDialog::new() - .add_filter(filmcraft_ui_egui::i18n::t(filter), &file_filters::extensions(exts)) - .set_file_name(name) - .save_file() - .map(|p| p.to_string_lossy().to_string()) - })); - app.hooks.pick_folder = Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string()))); - app.hooks.pick_folder_at = - Some(Box::new(|dir: &str| rfd::FileDialog::new().set_directory(dir).pick_folder().map(|p| p.to_string_lossy().into_owned()))); - app.hooks.open_path = Some(Box::new(open_path)); - // Help menu, About and Discord links (#642): eframe is built without its `links` - // feature, so `ctx.open_url` does nothing on the desktop. Detached, so a launcher - // that waits for the browser never freezes the UI. - app.hooks.open_url = Some(Box::new(|url: &str| open::that_detached(url).map_err(|e| e.to_string()))); - app.hooks.clipboard_media = Some(Box::new(clipboard_media)); - // Settings ▸ General ▸ Interface Language ▸ System Language (#218). - app.hooks.cursor_screen_position = Some(Box::new(filmcraft_platform::cursor::cursor_screen_position)); - app.hooks.system_languages = Some(Box::new(|| sys_locale::get_locales().collect())); - // Settings ▸ Appearance ▸ Appearance Mode ▸ Sync with system on Linux desktops whose - // compositor reports no theme to winit (no polling: see appearance.rs). - app.hooks.system_theme = appearance::service(); - app.hooks.raise_without_focus = Some(Box::new(|| { - window_raise::raise_without_focus(); - })); - app.hooks.pick_open_file = Some(Box::new(|filter: &str, exts: &[&str]| { - rfd::FileDialog::new() - .add_filter(filmcraft_ui_egui::i18n::t(filter), &file_filters::extensions(exts)) - .pick_file() - .map(|p| p.to_string_lossy().to_string()) - })); - app.hooks.pick_open_project = Some(Box::new(|| { - rfd::FileDialog::new() - .add_filter(filmcraft_ui_egui::i18n::t("FilmCraft Project"), &file_filters::extensions(&["fcproj"])) - .pick_file() - .map(|p| p.to_string_lossy().to_string()) - })); - #[cfg(target_os = "macos")] - { - let (rx, update) = native_menu::install(&app, cc.egui_ctx.clone()); - app.command_inbox = Some(rx); - app.hooks.shortcuts_changed = Some(update); - app.ui.show_menu_bar = false; - } - if let Some(port) = control_port { - let rx = control_server::start(port, cc.egui_ctx.clone()); - app = app.with_control(rx); - } - Ok(Box::new(app)) - }), - ); // The window could not be created (no usable GPU / graphics driver, no display): say so // instead of exiting silently, and keep the reason in the crash log. if let Err(e) = &started { @@ -288,6 +160,173 @@ fn main() -> eframe::Result { started } +/// The main window's options; built for each start attempt (they hold a one-shot hook). +fn native_options(agent: bool) -> eframe::NativeOptions { + eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_title("FilmCraft") + .with_inner_size([1600.0, 980.0]) + .with_min_inner_size([900.0, 560.0]) + .with_drag_and_drop(true) + .with_fullsize_content_view(true) + .with_titlebar_shown(false) + .with_title_shown(false) + // Started for an agent (`--control`): open without taking the user's keyboard focus. + .with_active(!agent) + .with_icon(app_icon()) + // Wayland matches the window to `packaging/linux/ai.storyteller.filmcraft.desktop`. + .with_app_id(APP_ID), + event_loop_builder: agent_event_loop(agent), + ..Default::default() + } +} + +/// Creates the app once eframe has its window and graphics device; sets `created` when it runs, +/// and confirms the graphics mode once two frames have painted. +fn app_creator(launch: Launch, graphics: graphics::GraphicsStart, created: Arc) -> eframe::AppCreator<'static> { + let Launch { control_port, files, demo, startup_flag, recover, data_dir } = launch; + Box::new(move |cc| { + created.store(true, Ordering::Relaxed); + let confirmed = AtomicBool::new(false); + cc.egui_ctx.on_end_pass( + "graphics-start", + Arc::new(move |ui| { + if ui.ctx().cumulative_frame_nr() >= 2 && !confirmed.swap(true, Ordering::Relaxed) { + graphics.confirm(); + } + }), + ); + let mut session = Session::default(); + if let Some(dir) = data_dir.clone().or_else(default_data_dir) { + let mut cfg = AutosaveConfig::new(dir); + cfg.local_offset = local_offset; + if let Err(e) = session.start_autosave(cfg) { + log::warn!("auto-save and crash recovery unavailable: {e}"); + } + } + // voice-over recording reads the microphone through cpal + session.voiceover.input = Some(Box::new(audio_in::CpalIn::new(&session.prefs.audio_hardware.device_class))); + let project = files.iter().find(|f| f.ends_with(".fcproj")).cloned(); + if let Some(p) = project { + if let Err(e) = session.execute("file.open", json!({"path": p})) { + log::error!("{e}"); + } + } else if !startup_flag && session.prefs.general.at_startup == "openMostRecent" { + // Settings ▸ General ▸ At Startup ▸ Open Most Recent + let recent = session.prefs.general.recent_projects.iter().find(|p| std::path::Path::new(p).exists()).cloned(); + match recent { + Some(p) => { + if let Err(e) = session.execute("file.open", json!({"path": p})) { + log::error!("{e}"); + } + } + None => { + let _ = session.execute("file.openDemoProject", json!({})); + } + } + } else if !startup_flag && session.prefs.general.at_startup == "emptyProject" { + } else if demo { + let _ = session.execute("file.openDemoProject", json!({})); + } + let media: Vec = files.iter().filter(|f| !f.ends_with(".fcproj")).cloned().collect(); + if !media.is_empty() { + let _ = session.execute("file.import", json!({"paths": media})); + } + if recover == Some(true) && !session.recovery_candidates().is_empty() { + let id = session.recovery_candidates()[0].id.clone(); + match session.execute("file.recover", json!({"id": id})) { + Ok(r) => log::info!("recovered {r}"), + Err(e) => log::error!("recovery failed: {e}"), + } + } + let mut app = FilmcraftApp::new(session); + if recover == Some(false) { + app.dialog = None; + } + app.integrated_titlebar = cfg!(target_os = "macos"); + if let Some(rs) = cc.wgpu_render_state.clone() + && std::env::var_os("FILMCRAFT_CPU_COMPOSITE").is_none() + { + app.set_wgpu(rs); + } + // Keep device selection available even when the default device is unavailable. + // Settings ▸ Audio Hardware is applied on the first frame (`apply_prefs`). + app.audio = Some(Box::new(audio::CpalOut::new())); + app.hooks.pick_files = Some(Box::new(|exts: &[&str]| { + rfd::FileDialog::new() + .add_filter(filmcraft_ui_egui::i18n::t("Media"), &file_filters::extensions(exts)) + .pick_files() + .unwrap_or_default() + .into_iter() + .map(|p| p.to_string_lossy().to_string()) + .collect() + })); + // Link Media ▸ Locate…, Attach Proxies, Reconnect Full Resolution: one path, not imported. + app.hooks.pick_file_for_relink = Some(Box::new(|exts: &[&str], _hint| { + rfd::FileDialog::new() + .add_filter(filmcraft_ui_egui::i18n::t("Media"), &file_filters::extensions(exts)) + .pick_file() + .map(|p| p.to_string_lossy().to_string()) + })); + app.hooks.pick_save = Some(Box::new(|name: &str| { + rfd::FileDialog::new() + .add_filter(filmcraft_ui_egui::i18n::t("FilmCraft Project"), &file_filters::extensions(&["fcproj"])) + .set_file_name(name) + .save_file() + .map(|p| p.to_string_lossy().to_string()) + })); + app.hooks.pick_save_as = Some(Box::new(|filter: &str, exts: &[&str], name: &str| { + rfd::FileDialog::new() + .add_filter(filmcraft_ui_egui::i18n::t(filter), &file_filters::extensions(exts)) + .set_file_name(name) + .save_file() + .map(|p| p.to_string_lossy().to_string()) + })); + app.hooks.pick_folder = Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string()))); + app.hooks.pick_folder_at = + Some(Box::new(|dir: &str| rfd::FileDialog::new().set_directory(dir).pick_folder().map(|p| p.to_string_lossy().into_owned()))); + app.hooks.open_path = Some(Box::new(open_path)); + // Help menu, About and Discord links (#642): eframe is built without its `links` + // feature, so `ctx.open_url` does nothing on the desktop. Detached, so a launcher + // that waits for the browser never freezes the UI. + app.hooks.open_url = Some(Box::new(|url: &str| open::that_detached(url).map_err(|e| e.to_string()))); + app.hooks.clipboard_media = Some(Box::new(clipboard_media)); + // Settings ▸ General ▸ Interface Language ▸ System Language (#218). + app.hooks.cursor_screen_position = Some(Box::new(filmcraft_platform::cursor::cursor_screen_position)); + app.hooks.system_languages = Some(Box::new(|| sys_locale::get_locales().collect())); + // Settings ▸ Appearance ▸ Appearance Mode ▸ Sync with system on Linux desktops whose + // compositor reports no theme to winit (no polling: see appearance.rs). + app.hooks.system_theme = appearance::service(); + app.hooks.raise_without_focus = Some(Box::new(|| { + window_raise::raise_without_focus(); + })); + app.hooks.pick_open_file = Some(Box::new(|filter: &str, exts: &[&str]| { + rfd::FileDialog::new() + .add_filter(filmcraft_ui_egui::i18n::t(filter), &file_filters::extensions(exts)) + .pick_file() + .map(|p| p.to_string_lossy().to_string()) + })); + app.hooks.pick_open_project = Some(Box::new(|| { + rfd::FileDialog::new() + .add_filter(filmcraft_ui_egui::i18n::t("FilmCraft Project"), &file_filters::extensions(&["fcproj"])) + .pick_file() + .map(|p| p.to_string_lossy().to_string()) + })); + #[cfg(target_os = "macos")] + { + let (rx, update) = native_menu::install(&app, cc.egui_ctx.clone()); + app.command_inbox = Some(rx); + app.hooks.shortcuts_changed = Some(update); + app.ui.show_menu_bar = false; + } + if let Some(port) = control_port { + let rx = control_server::start(port, cc.egui_ctx.clone()); + app = app.with_control(rx); + } + Ok(Box::new(app)) + }) +} + /// Files copied in the file manager, else an image, on the system clipboard (Paste, #611). fn clipboard_media() -> Option { let mut clipboard = arboard::Clipboard::new().ok()?; diff --git a/docs/gaps.md b/docs/gaps.md index 83fb12b3..7d3bd17b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -79,6 +79,11 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** ## G4. Crashes at start-up, and no test CI on pull requests +- **Have:** a graphics start-up fallback (#820): a start that dies before its window paints (a + driver crash in wgpu instance / device creation) leaves a marker in the data folder, and the next + launch moves on to DirectX 12 only (Windows), then a software adapter (WARP, lavapipe, llvmpipe) + with CPU compositing, and keeps the mode that works; a start-up error wgpu reports moves on in + the same process. An explicit `WGPU_BACKEND` stays in charge. - **Missing:** a launch that never fails, and CI that runs the gates. - **Evidence:** open issues: macOS 12 Monterey aborts on a missing VideoToolbox symbol (#468, #661, #655), Intel UHD graphics (`igvk64.dll`, #512), Windows 11 does not run or fails after setup @@ -87,9 +92,10 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** `cargo test` or `cargo xtask ci` on a pull request**, so 243 PRs merged since 10-05 were gated by contributors' local runs (main needed "green again after the batch merges" on 10-10). - **Impact:** a user who cannot start the app gets zero value; regressions land unnoticed. -- **Done when:** weak-linked or runtime-loaded OS symbols on old macOS, a software / safe-mode - start when the GPU adapter fails, a PR workflow running `cargo xtask ci` on macOS, Windows and - Linux, and a crash-report triage pass. **40–80 h.** +- **Done when:** weak-linked or runtime-loaded OS symbols on old macOS, ~~a software / safe-mode + start when the GPU adapter fails~~ (done, #820; a way back to the default mode from Settings is + still missing: today the marker file is deleted by hand), a PR workflow running `cargo xtask ci` + on macOS, Windows and Linux, and a crash-report triage pass. **40–80 h.** ## G5. Hardware acceleration is partial