diff --git a/crates/engine/src/render_queue.rs b/crates/engine/src/render_queue.rs index 1bf95577..666a56cc 100644 --- a/crates/engine/src/render_queue.rs +++ b/crates/engine/src/render_queue.rs @@ -157,6 +157,14 @@ struct Work { nested_switches: bool, } +/// Where a relative render output goes when there is no default folder and the project is unsaved: +/// `~/Movies` when it exists, else the home folder itself. +pub(crate) fn default_output_base(home: Option) -> Option { + let home = home.filter(|h| !h.as_os_str().is_empty())?; + let movies = home.join("Movies"); + Some(if movies.is_dir() { movies } else { home }) +} + /// Bits per channel an output format writes for a project bit depth (named in the file when /// Settings ▸ Export ▸ Append Bit Depth to File Name is on). pub fn output_bits(format: OutputFormat, depth: effectcraft_project::BitDepth) -> u32 { @@ -324,7 +332,7 @@ impl Session { /// The output path of a queue item: template tokens expanded, ending in the format's /// extension exactly once ([`OutputFormat::with_extension`]), relative paths resolved against - /// the project's folder (or the working directory). `None` when the comp is gone or the + /// the project's folder (an unsaved project's against ~/Movies or home). `None` when the comp is gone or the /// output is empty ("Needs Output"). pub fn resolve_output(&self, item: &RenderQueueItem) -> Option { let comp = self.project.comp(item.comp)?; @@ -351,6 +359,10 @@ impl Session { let base = (!folder.is_empty()).then(|| std::path::PathBuf::from(folder)); let base = base.or_else(|| self.path.as_deref().and_then(|s| std::path::Path::new(s).parent().map(|d| d.to_path_buf())).filter(|d| !d.as_os_str().is_empty())); + // Unsaved project: ~/Movies, else the home folder (as FilmCraft's exports do). The working directory + // is `/` for an app started from the macOS Finder, where nothing can be written (#446). + let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(std::path::PathBuf::from); + let base = base.or_else(|| default_output_base(home)); // (the web has no working directory: relative outputs land in its virtual root) let base = base.or_else(|| std::env::current_dir().ok()).or_else(|| cfg!(target_arch = "wasm32").then(|| "/".into()))?; Some(base.join(path).to_string_lossy().to_string()) diff --git a/crates/engine/src/tests_m145.rs b/crates/engine/src/tests_m145.rs index b55442eb..972bcdc3 100644 --- a/crates/engine/src/tests_m145.rs +++ b/crates/engine/src/tests_m145.rs @@ -682,3 +682,13 @@ fn dynamic_menus_and_window_viewer_labels() { assert!(labels.iter().any(|x| x == l), "{l}"); } } + +#[test] +fn unsaved_project_renders_to_movies_or_home_not_the_working_directory() { + // A Finder-launched app has `/` as its working directory, where renders fail (#446). + let home = tmp("home446"); + assert_eq!(crate::render_queue::default_output_base(Some(home.clone())), Some(home.clone())); + std::fs::create_dir_all(home.join("Movies")).unwrap(); + assert_eq!(crate::render_queue::default_output_base(Some(home.clone())), Some(home.join("Movies"))); + assert_eq!(crate::render_queue::default_output_base(None), None); +}