From 7d508e2e4437274d8ba18c81f95d6bb222f61aa7 Mon Sep 17 00:00:00 2001 From: Zinadin Zidan Date: Sat, 7 Nov 2026 00:27:09 +0600 Subject: [PATCH] fix(engine): Bounce Stems and Export Clips work in the web build (#1409) Both made a folder first, which fails in the browser. Making a folder is now a no-op on wasm32, where each file is delivered by name, and Export Clips writes through write_file, the path bounce and export already use, instead of std::fs. Two clips with the same name no longer get the same file name when nothing is on disk. Native behaviour is unchanged. --- crates/engine/src/io.rs | 59 ++++++++++++++++++++++++++++++----------- 1 file changed, 44 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 571e7ffd..37768f29 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -178,8 +178,12 @@ pub fn stored_path(stored: &str, session: &Path) -> std::result::Result) -> Result<()> { + if cfg!(target_arch = "wasm32") { + return Ok(()); + } let dir = dir.as_ref(); // The empty folder of a bare file name ("t.scraft") is the working directory, which the root // check would refuse as an empty path. @@ -563,29 +567,37 @@ impl From for soundcraft_model::AutoName { } } +/// Write each clip as a WAV file named after it into `dir` (in the browser, delivered by name +/// through [`write_file`]). Returns how many were written. pub fn export_clips(e: &Engine, ids: &[soundcraft_model::ClipId], dir: &str) -> Result { let s = e.session(); make_dir(dir)?; - let mut n = 0; + let mut taken = std::collections::HashSet::new(); for id in ids { let Some((t, c)) = s.find_clip(*id) else { continue }; let audio = soundcraft_mix::render_clips(s, t, c.range()); let buf = AudioBuffer { sample_rate: s.sample_rate.hz(), channels: audio }; let bytes = soundcraft_audio_io::encode(&buf, &EncodeOptions::default()).map_err(|err| EngineError::Io(err.to_string()))?; - let stem = sanitize_name(&c.name); - let mut path = Path::new(dir).join(format!("{stem}.wav")); - let mut k = 2u32; - while path.exists() { - path = Path::new(dir).join(format!("{stem}-{k}.wav")); - k = k.saturating_add(1); - if k > 10_000 { - return Err(EngineError::Io(format!("{dir}: too many files named {stem}"))); - } - } - write_path(&path, bytes)?; - n += 1; + let path = clip_file(dir, &sanitize_name(&c.name), &taken)?; + write_file(&path.to_string_lossy(), &bytes)?; + taken.insert(path); } - Ok(n) + Ok(taken.len()) +} + +/// The first of `.wav`, `-2.wav`, … in `dir` that is neither on disk nor `taken` by +/// this export: the browser has no disk to look at, so same-named clips are kept apart by `taken`. +fn clip_file(dir: &str, stem: &str, taken: &std::collections::HashSet) -> Result { + let mut path = Path::new(dir).join(format!("{stem}.wav")); + let mut k = 2u32; + while path.exists() || taken.contains(&path) { + path = Path::new(dir).join(format!("{stem}-{k}.wav")); + k = k.saturating_add(1); + if k > 10_000 { + return Err(EngineError::Io(format!("{dir}: too many files named {stem}"))); + } + } + Ok(path) } /// Write `session` to `path`; returns the session with updated media paths, the number of audio @@ -996,3 +1008,20 @@ mod stem_tests { let _ = std::fs::remove_dir_all(&root); } } + +#[cfg(test)] +mod clip_tests { + /// The browser has no disk to find earlier files on: same-named clips get their own names + /// from the export itself, as they do from the folder natively (#1409). + #[test] + fn same_named_clips_get_their_own_names_with_nothing_on_disk() { + let dir = std::env::temp_dir().join(format!("sc-clip-names-{}", std::process::id())); + let mut taken = std::collections::HashSet::new(); + for want in ["Take.wav", "Take-2.wav", "Take-3.wav"] { + let path = super::clip_file(&dir.to_string_lossy(), "Take", &taken).unwrap(); + assert_eq!(path, dir.join(want)); + taken.insert(path); + } + assert!(!dir.exists()); + } +}