Fix save crash on zero/sparse-audio projects (phantom pool placeholder)

`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.
This commit is contained in:
Skyler Lehmkuhl 2026-06-20 18:46:06 -04:00
parent 39dc402ba3
commit d001ca1083
2 changed files with 22 additions and 6 deletions

View File

@ -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();

View File

@ -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()