Fix .beam save quirks: real sample rate, drop dead fields

Address the code smells flagged in the .beam format spec:

- Write the project's actual sample rate on save instead of a hardcoded 48000
  (add AudioProject::sample_rate()).
- Remove the vestigial RasterKeyframe.media_path field (it was only used by the
  legacy ZIP loader, which now derives "media/raster/<id>.png" from the keyframe
  id) and the dead buffer_path_at_time accessor. Backward-compatible: older files
  carrying media_path deserialize fine (the field is ignored).
- Drop the unused SaveSettings fields auto_embed_threshold_bytes / force_embed_all
  / force_link_all; only large_media_mode was ever consulted. Un-prefix the now-used
  `settings` parameter.

Update BEAM_FILE_FORMAT.md to match. The remaining notes (reserved MediaKind::Video,
exact-match version check) are design choices, left as-is.
This commit is contained in:
Skyler Lehmkuhl 2026-06-21 23:21:39 -04:00
parent 5f0d5354ed
commit 34eee3a620
5 changed files with 24 additions and 40 deletions

View File

@ -255,15 +255,15 @@ Collections that carry media linkage are **bold**:
`RasterKeyframe` (within the layer tree) has `id: Uuid` (= its full-res `Raster`
media id and the seed for its `RasterProxy` id), `time`, `width`, `height`,
`tween_after`, `stroke_log`, and a `media_path` string that is **vestigial** in
SQLite files (it names the legacy ZIP entry path; the SQLite path keys on `id` and
ignores it). Pixel buffers are `#[serde(skip)]` and faulted in from media rows.
`tween_after`, and `stroke_log`. Pixel buffers are `#[serde(skip)]` and faulted in
from media rows. (The legacy ZIP entry path `media/raster/<id>.png` is derived from
`id`; older files may also carry a now-ignored `media_path` field.)
### 8.3 `SerializedAudioBackend`
| Field | Type | Notes |
|----------------------|---------------------|-------|
| `sample_rate` | `u32` | **Unreliable:** the current writer hardcodes `48000` here; authoritative rates are on `AudioProject.sample_rate` and each `AudioPoolEntry.sample_rate`. |
| `sample_rate` | `u32` | The project's system sample rate (mirrors `AudioProject.sample_rate`). Per-file rates are on each `AudioPoolEntry.sample_rate`. |
| `project` | `AudioProject` | Tracks + MIDI clip pool (§8.4). |
| `audio_pool_entries` | `[AudioPoolEntry]` | Audio source registry (§8.5). |
| `layer_to_track_map` | `map<Uuid, u32>` | UI layer UUID → engine track id. `#[serde(default)]`. |
@ -388,8 +388,8 @@ internal layout:
extension. **FLAC entries are decoded to PCM `f32` and re-encoded in memory as
IEEE-float WAV (WAV format tag 3)** before being base64-embedded; other formats are
embedded as-is. The entry's `relative_path` is cleared after extraction.
- `media/raster/<uuid>.png` — raster keyframe pixels, named by
`RasterKeyframe.media_path`.
- `media/raster/<uuid>.png` — raster keyframe pixels, named `media/raster/<id>.png`
after the keyframe's `id`.
A reader MUST NOT modify a legacy ZIP on open; it loads into memory only. **The next
save migrates the project to SQLite** (the save sees a non-SQLite target, so it takes
@ -451,18 +451,15 @@ A conforming **writer** MUST:
These reflect the current reference implementation and are flagged for implementers
and future spec revisions:
- `SerializedAudioBackend.sample_rate` is hardcoded to `48000` on save and SHOULD be
ignored in favour of `AudioProject.sample_rate` / `AudioPoolEntry.sample_rate`.
- `MediaKind::Video` (`1`) is defined but never written; video media is always an
external file via `VideoClip.file_path`.
- `RasterKeyframe.media_path` is serialized but unused by the SQLite path (vestigial
legacy-ZIP linkage).
- The reference writer's `SaveSettings` fields `auto_embed_threshold_bytes`,
`force_embed_all`, and `force_link_all` are not consulted by the current save path;
only the large-media mode is.
- The project `version` check is exact-match with no compatibility window; a future
revision should define semantic-version tolerance before bumping it.
(Earlier revisions of this section flagged a hardcoded save sample rate, a vestigial
`RasterKeyframe.media_path` field, and unused `SaveSettings` fields; these have since
been fixed/removed.)
## 16. Inspecting a `.beam` file
Because the container is plain SQLite, any `.beam` (SQLite variant) can be inspected

View File

@ -41,6 +41,11 @@ impl Project {
}
}
/// The project's system sample rate in Hz.
pub fn sample_rate(&self) -> u32 {
self.sample_rate
}
/// Generate a new unique track ID
fn next_id(&mut self) -> TrackId {
let id = self.next_track_id;

View File

@ -88,15 +88,6 @@ impl Default for LargeMediaMode {
/// Settings for saving a project
#[derive(Debug, Clone)]
pub struct SaveSettings {
/// Automatically embed files smaller than this size (in bytes)
pub auto_embed_threshold_bytes: u64,
/// Force embedding all media files
pub force_embed_all: bool,
/// Force linking all media files (don't embed any)
pub force_link_all: bool,
/// How to store files at/above [`LARGE_MEDIA_THRESHOLD`] (pack vs reference).
/// `Ask` behaves as `Reference` here (safe default: don't bloat the DB).
pub large_media_mode: LargeMediaMode,
@ -105,9 +96,6 @@ pub struct SaveSettings {
impl Default for SaveSettings {
fn default() -> Self {
Self {
auto_embed_threshold_bytes: 10_000_000, // 10 MB
force_embed_all: false,
force_link_all: false,
large_media_mode: LargeMediaMode::Ask,
}
}
@ -269,7 +257,7 @@ pub fn save_beam(
audio_pool_entries: Vec<AudioPoolEntry>,
layer_to_track_map: &std::collections::HashMap<uuid::Uuid, u32>,
thumbnail_blobs: &std::collections::HashMap<uuid::Uuid, Vec<u8>>,
_settings: &SaveSettings,
settings: &SaveSettings,
) -> Result<(), String> {
let fn_start = std::time::Instant::now();
eprintln!("📊 [SAVE_BEAM] Starting save_beam() (SQLite container)...");
@ -349,7 +337,7 @@ pub fn save_beam(
// (`Ask` == reference); smaller files are always packed.
let reference_it = entry.is_video_audio
|| (size >= LARGE_MEDIA_THRESHOLD
&& _settings.large_media_mode != LargeMediaMode::Pack);
&& settings.large_media_mode != LargeMediaMode::Pack);
if reference_it {
referenced = Some(rel.clone());
} else {
@ -498,7 +486,7 @@ pub fn save_beam(
modified: now.clone(),
ui_state: document.clone(),
audio_backend: SerializedAudioBackend {
sample_rate: 48000, // TODO: Get from audio engine
sample_rate: audio_project.sample_rate(),
project: audio_project.clone(),
audio_pool_entries: modified_entries,
layer_to_track_map: layer_to_track_map.clone(),
@ -879,8 +867,11 @@ fn load_beam_zip_legacy(path: &Path) -> Result<LoadedProject, String> {
for layer in document.root.children.iter_mut() {
if let crate::layer::AnyLayer::Raster(rl) = layer {
for kf in &mut rl.keyframes {
if !kf.media_path.is_empty() {
match zip.by_name(&kf.media_path) {
// Legacy ZIP raster entries are named "media/raster/<uuid>.png",
// derivable from the keyframe id (the old `media_path` field).
let entry_path = format!("media/raster/{}.png", kf.id);
{
match zip.by_name(&entry_path) {
Ok(mut png_file) => {
let mut png_bytes = Vec::new();
let _ = png_file.read_to_end(&mut png_bytes);
@ -890,7 +881,7 @@ fn load_beam_zip_legacy(path: &Path) -> Result<LoadedProject, String> {
kf.raw_pixels = rgba.into_raw();
raster_load_count += 1;
}
Err(e) => eprintln!("⚠️ [LOAD_BEAM] Failed to decode raster PNG {}: {}", kf.media_path, e),
Err(e) => eprintln!("⚠️ [LOAD_BEAM] Failed to decode raster PNG {}: {}", entry_path, e),
}
}
Err(_) => {

View File

@ -111,8 +111,6 @@ pub struct RasterKeyframe {
pub time: f64,
pub width: u32,
pub height: u32,
/// ZIP-relative path: `"media/raster/<uuid>.png"`
pub media_path: String,
/// Stroke history (for potential replay / future non-destructive editing)
pub stroke_log: Vec<StrokeRecord>,
pub tween_after: TweenType,
@ -157,13 +155,11 @@ impl RasterKeyframe {
pub fn new(time: f64, width: u32, height: u32) -> Self {
let id = Uuid::new_v4();
let media_path = format!("media/raster/{}.png", id);
Self {
id,
time,
width,
height,
media_path,
stroke_log: Vec::new(),
tween_after: TweenType::Hold,
raw_pixels: Vec::new(),
@ -264,10 +260,6 @@ impl RasterLayer {
.map(|pos| self.keyframes.remove(pos))
}
/// Return the ZIP-relative PNG path for the active keyframe at `time`, or `None`.
pub fn buffer_path_at_time(&self, time: f64) -> Option<&str> {
self.keyframe_at(time).map(|kf| kf.media_path.as_str())
}
}
// Delegate all LayerTrait methods to self.layer

View File

@ -731,7 +731,6 @@ impl FileOperationsWorker {
let settings = SaveSettings {
large_media_mode,
..SaveSettings::default()
};
match save_beam(&path, &document, &mut audio_project, audio_pool_entries, layer_to_track_map, &thumbnail_blobs, &settings) {
Ok(()) => {