diff --git a/crates/engine/src/export_tests.rs b/crates/engine/src/export_tests.rs index 79ddcb810..2e6eb8a99 100644 --- a/crates/engine/src/export_tests.rs +++ b/crates/engine/src/export_tests.rs @@ -9,6 +9,16 @@ use serde_json::{Value, json}; use crate::Session; +#[test] +fn project_names_become_one_export_filename_component() { + for name in ["../../outside", "/absolute/path", r"C:\\outside", ".."] { + let safe = crate::export_tools::file_safe(name); + assert!(!safe.contains(['/', '\\']), "{name:?} became {safe:?}"); + assert_ne!(safe, "."); + assert_ne!(safe, ".."); + } +} + #[test] fn hostile_export_ranges_are_rejected_without_panicking() { let mut session = demo(); diff --git a/crates/engine/src/export_tools.rs b/crates/engine/src/export_tools.rs index 98c7ad2fc..170bc142b 100644 --- a/crates/engine/src/export_tools.rs +++ b/crates/engine/src/export_tools.rs @@ -385,10 +385,11 @@ pub fn default_export_dir(s: &Session) -> PathBuf { crate::temp_dir() } -fn file_safe(n: &str) -> String { +/// Convert project-derived display text into exactly one safe output filename component. +pub fn file_safe(n: &str) -> String { let s: String = n.chars().map(|c| if c.is_alphanumeric() || " -_().".contains(c) { c } else { '_' }).collect(); let s = s.trim().to_string(); - if s.is_empty() { "Export".into() } else { s } + if s.is_empty() || matches!(s.as_str(), "." | "..") { "Export".into() } else { s } } /// `path` with the settings' extension (added when missing; image sequences keep theirs). diff --git a/crates/ui-egui/src/panels/export_mode.rs b/crates/ui-egui/src/panels/export_mode.rs index b8082433c..f1cf5224d 100644 --- a/crates/ui-egui/src/panels/export_mode.rs +++ b/crates/ui-egui/src/panels/export_mode.rs @@ -1414,7 +1414,9 @@ fn quick_file(app: &FilmcraftApp, path: &str, seq_name: &str, ext: &str) -> Opti return None; } let file = match name { - None => format!("{seq_name}.{ext}"), + // Sequence names come from the project, not from this Quick Export interaction. Keep + // metadata in one filename component before joining it to the selected/default folder. + None => format!("{}.{}", filmcraft_engine::export_tools::file_safe(seq_name), ext), Some(n) if cur.eq_ignore_ascii_case(ext) => n.to_string(), Some(n) if !cur.is_empty() && app.session.export_presets.all().iter().any(|x| x.settings.extension().eq_ignore_ascii_case(cur)) => { std::path::Path::new(n).with_extension(ext).to_string_lossy().to_string()