From d001ca108376168bb4b8f7492ca8d0fcc7aa3703 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Sat, 20 Jun 2026 18:46:06 -0400 Subject: [PATCH] Fix save crash on zero/sparse-audio projects (phantom pool placeholder) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AudioPool::load_from_serialized` sizes the slot Vec by pool_index and fills gaps with empty `AudioFile::new(PathBuf::new(), …)` placeholders. Two bugs let a placeholder reach the next save and abort it with "Is a directory": - Off-by-one: `entries.max().unwrap_or(0) + 1` made an *empty* pool length 1, so a project with no audio still got one placeholder. Size by `max(pool_index + 1)` → empty entries yield length 0. - `serialize()` emitted placeholder slots: an empty path round-trips to `relative_path = Some("")`, which `save_beam` resolves to the project directory (`join("")`) and tries to read as media. Skip empty-path / no-packed-media slots. - Defense in `save_beam`: gate referenced-media packing on `full.is_file()` (not `exists()`), so any blank/dir path falls through to embedded data instead of reading a directory. Pre-existing; surfaced by a save → reload → save cycle on a raster-only project. --- daw-backend/src/audio/pool.rs | 22 ++++++++++++++----- .../lightningbeam-core/src/file_io.rs | 6 ++++- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/daw-backend/src/audio/pool.rs b/daw-backend/src/audio/pool.rs index 6e329ad..7a90f50 100644 --- a/daw-backend/src/audio/pool.rs +++ b/daw-backend/src/audio/pool.rs @@ -926,6 +926,16 @@ impl AudioClipPool { let mut entries = Vec::new(); for (index, file) in self.files.iter().enumerate() { + // Skip placeholder pool slots: `load_from_serialized` resizes the pool to + // `max_index + 1` filled with empty `AudioFile::new(PathBuf::new(), …)` to + // cover index gaps (and creates one even when there are no entries at all). + // Such a slot has an empty path and no packed media — there's nothing to + // persist, and emitting it yields an entry whose empty `relative_path` + // resolves to the project directory itself (unreadable on the next save). + if file.path.as_os_str().is_empty() && file.packed_media_id.is_none() { + continue; + } + // Video's audio track: reference the video file (it's also referenced // by the VideoClip) and re-probe it via FFmpeg on load. Never pack or // embed it as audio media — that both wastes space and loses the 5.1+ @@ -1141,16 +1151,18 @@ impl AudioClipPool { self.files.clear(); eprintln!("📊 [LOAD_SERIALIZED] Clear pool took {:.2}ms", clear_start.elapsed().as_secs_f64() * 1000.0); - // Find the maximum pool index to determine required size - let max_index = entries.iter() - .map(|e| e.pool_index) + // Size the pool to hold the highest pool_index (slots are addressed by index, + // so gaps are filled with placeholders). No entries → length 0, NOT 1: the old + // `max().unwrap_or(0) + 1` produced a spurious placeholder for an empty pool. + let pool_size = entries.iter() + .map(|e| e.pool_index + 1) .max() .unwrap_or(0); // Ensure we have space for all entries let resize_start = std::time::Instant::now(); - self.files.resize(max_index + 1, AudioFile::new(PathBuf::new(), Vec::new(), 2, 44100)); - eprintln!("📊 [LOAD_SERIALIZED] Resize pool to {} took {:.2}ms", max_index + 1, resize_start.elapsed().as_secs_f64() * 1000.0); + self.files.resize(pool_size, AudioFile::new(PathBuf::new(), Vec::new(), 2, 44100)); + eprintln!("📊 [LOAD_SERIALIZED] Resize pool to {} took {:.2}ms", pool_size, resize_start.elapsed().as_secs_f64() * 1000.0); for (i, entry) in entries.iter().enumerate() { let entry_start = std::time::Instant::now(); diff --git a/lightningbeam-ui/lightningbeam-core/src/file_io.rs b/lightningbeam-ui/lightningbeam-core/src/file_io.rs index 78d4fd2..986580b 100644 --- a/lightningbeam-ui/lightningbeam-core/src/file_io.rs +++ b/lightningbeam-ui/lightningbeam-core/src/file_io.rs @@ -324,7 +324,11 @@ pub fn save_beam( } else { project_dir.join(rel) }; - if full.exists() { + // Require an actual file: an empty/blank `relative_path` resolves to the + // project directory itself (`join("")` == dir), which `exists()` accepts + // but can't be read as media. `is_file()` skips dirs + missing paths, so + // such an entry correctly falls through to embedded data below. + if full.is_file() { let size = std::fs::metadata(&full).map(|m| m.len()).unwrap_or(0); let codec = full .extension()