diff --git a/crates/project/src/comp.rs b/crates/project/src/comp.rs index aa4ff7201..3bce67960 100644 --- a/crates/project/src/comp.rs +++ b/crates/project/src/comp.rs @@ -271,7 +271,15 @@ impl Comp { return base.to_string(); } let stem = base.trim_end_matches(|c: char| c.is_ascii_digit()).trim_end(); - (2..).map(|i| format!("{stem} {i}")).find(|n| !self.layers.iter().any(|l| &l.name == n)).unwrap_or_default() + // The suffixes in use, gathered in one pass: trying each candidate against every layer + // made importing many layers of one name cubic. + let taken: std::collections::HashSet = self + .layers + .iter() + .filter_map(|l| l.name.strip_prefix(stem)?.strip_prefix(' ')) + .filter_map(|s| s.parse::().ok().filter(|n| n.to_string() == s)) + .collect(); + (2..).find(|i| !taken.contains(i)).map(|i| format!("{stem} {i}")).unwrap_or_default() } } diff --git a/crates/project/src/render_queue.rs b/crates/project/src/render_queue.rs index c40096905..f7759cff4 100644 --- a/crates/project/src/render_queue.rs +++ b/crates/project/src/render_queue.rs @@ -1529,7 +1529,11 @@ pub fn numbered_template(template: &str, n: u32) -> String { /// Make a name safe as a file name component. pub fn sanitize(name: &str) -> String { - name.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') { '_' } else { c }).collect() + // At most a file name's worth (255 characters): a template repeating a long name token + // must not expand to gigabytes. + let s: String = name.chars().take(255).map(|c| if matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') { '_' } else { c }).collect(); + // `.` and `..` would name the folder itself or its parent when the token is a path component. + if !s.is_empty() && s.chars().all(|c| c == '.') { "_".repeat(s.len()) } else { s } } #[cfg(test)] @@ -1540,6 +1544,14 @@ mod tests { fn templates_and_sequences() { let v = TemplateVars { comp_name: "Main/Title", project_name: "P", width: 1920, height: 1080, frame_rate: 29.97, start_frame: 0, end_frame: 9 }; assert_eq!(expand_template(DEFAULT_TEMPLATE, OutputFormat::H264, &v), "Main_Title.mp4"); + // A dot-only name is not a parent-folder component; a long one is cut to a file name. + for name in ["..", "."] { + let v = TemplateVars { comp_name: name, ..v }; + assert_eq!(expand_template("out/[compName]/x.[fileExtension]", OutputFormat::Gif, &v), format!("out/{}/x.gif", "_".repeat(name.len()))); + } + let long = "n".repeat(100_000); + let v2 = TemplateVars { comp_name: &long, ..v }; + assert_eq!(expand_template(&"[compName]".repeat(100), OutputFormat::Gif, &v2).len(), 255 * 100); assert_eq!(expand_template("[compName]_[width]x[height]@[frameRate].[fileExtension]", OutputFormat::Gif, &v), "Main_Title_1920x1080@29.97.gif"); assert_eq!(sequence_path("out/a_[#####].png", 42), "out/a_00042.png"); assert_eq!(sequence_path("a###.tif", 7), "a007.tif"); diff --git a/crates/project/src/tests.rs b/crates/project/src/tests.rs index a47792b89..a5f0bbab1 100644 --- a/crates/project/src/tests.rs +++ b/crates/project/src/tests.rs @@ -119,6 +119,14 @@ fn unique_names_and_cycles() { let c = p.comp(cid).unwrap(); assert_eq!(c.unique_layer_name("Red Solid 1"), "Red Solid 2"); assert_eq!(c.unique_layer_name("Shape Layer 1"), "Shape Layer 1"); + // A suffix written another way (`02`) is not the number taken. + let mut d = c.clone(); + for n in ["Red Solid 02", "Red Solid 3"] { + let mut l = d.layers[0].clone(); + l.name = n.into(); + d.layers.push(l); + } + assert_eq!(d.unique_layer_name("Red Solid 1"), "Red Solid 2"); assert!(p.comp_contains(cid, cid)); }