Merge pull request #1416 from ZIDAN44/fix/1409-web-export-clips

fix(engine): Bounce Stems and Export Clips work in the web build (#1409)
This commit is contained in:
FredApps authored and GitHub committed 2026-11-07 15:40:58 +01:00
commit fbd7789b4a
1 file changed
+79 -15
+79 -15
View File
@@ -178,8 +178,12 @@ pub fn stored_path(stored: &str, session: &Path) -> std::result::Result<PathBuf,
craft_docpath::resolve_absolute(stored, Some(&session)).map_err(|why| format!("not opened: {why}"))
}
/// Create `dir` and its parents, inside the automation write root when there is one.
/// Create `dir` and its parents, inside the automation write root when there is one. The browser
/// has no folders: there each file is delivered by its name ([`write_file`]) and this does nothing.
pub(crate) fn make_dir(dir: impl AsRef<Path>) -> 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,48 @@ impl From<StripNames> 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<usize> {
let s = e.session();
make_dir(dir)?;
let mut n = 0;
let mut names = ClipNames::default();
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), &mut names, Path::exists)?;
write_file(&path.to_string_lossy(), &bytes)?;
}
Ok(n)
Ok(names.taken.len())
}
/// The files an export has named so far, and for each name the suffix to try next, so a run of
/// same-named clips doesn't count up from 2 again for every clip.
#[derive(Default)]
struct ClipNames {
taken: std::collections::HashSet<PathBuf>,
next: std::collections::HashMap<String, u32>,
}
/// The first of `<stem>.wav`, `<stem>-2.wav`, … in `dir` that is neither `on_disk` nor already
/// named by this export, recorded in `names`: the browser has no disk to look at, so same-named
/// clips are kept apart by `names`.
fn clip_file(dir: &str, stem: &str, names: &mut ClipNames, on_disk: impl Fn(&Path) -> bool) -> Result<PathBuf> {
let file = |k: u32| Path::new(dir).join(if k < 2 { format!("{stem}.wav") } else { format!("{stem}-{k}.wav") });
let mut k = names.next.get(stem).copied().unwrap_or(1);
let mut path = file(k);
while on_disk(&path) || names.taken.contains(&path) {
k = k.saturating_add(1);
if k > 10_000 {
return Err(EngineError::Io(format!("{dir}: too many files named {stem}")));
}
path = file(k);
}
names.next.insert(stem.to_owned(), k.saturating_add(1));
names.taken.insert(path.clone());
Ok(path)
}
/// Write `session` to `path`; returns the session with updated media paths, the number of audio
@@ -996,3 +1019,44 @@ 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 names = super::ClipNames::default();
for want in ["Take.wav", "Take-2.wav", "Take-3.wav"] {
let path = super::clip_file(&dir.to_string_lossy(), "Take", &mut names, std::path::Path::exists).unwrap();
assert_eq!(path, dir.join(want));
}
assert!(!dir.exists());
}
/// A file already in the folder is still skipped, and a run of same-named clips picks up where
/// the last one left off instead of looking at every earlier name again for each clip.
#[test]
fn same_named_clips_skip_files_on_disk_and_do_not_count_up_from_the_start_each_time() {
let dir = std::env::temp_dir().join(format!("sc-clip-disk-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("Take-2.wav"), b"").unwrap();
let looked = std::cell::Cell::new(0usize);
let on_disk = |p: &std::path::Path| {
looked.set(looked.get() + 1);
p.exists()
};
let mut names = super::ClipNames::default();
for want in ["Take.wav", "Take-3.wav", "Take-4.wav"] {
let path = super::clip_file(&dir.to_string_lossy(), "Take", &mut names, on_disk).unwrap();
assert_eq!(path, dir.join(want));
}
let before = looked.get();
for _ in 0..1_000 {
super::clip_file(&dir.to_string_lossy(), "Take", &mut names, on_disk).unwrap();
}
assert_eq!(looked.get() - before, 1_000);
let _ = std::fs::remove_dir_all(&dir);
}
}