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:
parent
5f0d5354ed
commit
34eee3a620
|
|
@ -255,15 +255,15 @@ Collections that carry media linkage are **bold**:
|
||||||
|
|
||||||
`RasterKeyframe` (within the layer tree) has `id: Uuid` (= its full-res `Raster`
|
`RasterKeyframe` (within the layer tree) has `id: Uuid` (= its full-res `Raster`
|
||||||
media id and the seed for its `RasterProxy` id), `time`, `width`, `height`,
|
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
|
`tween_after`, and `stroke_log`. Pixel buffers are `#[serde(skip)]` and faulted in
|
||||||
SQLite files (it names the legacy ZIP entry path; the SQLite path keys on `id` and
|
from media rows. (The legacy ZIP entry path `media/raster/<id>.png` is derived from
|
||||||
ignores it). Pixel buffers are `#[serde(skip)]` and faulted in from media rows.
|
`id`; older files may also carry a now-ignored `media_path` field.)
|
||||||
|
|
||||||
### 8.3 `SerializedAudioBackend`
|
### 8.3 `SerializedAudioBackend`
|
||||||
|
|
||||||
| Field | Type | Notes |
|
| 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). |
|
| `project` | `AudioProject` | Tracks + MIDI clip pool (§8.4). |
|
||||||
| `audio_pool_entries` | `[AudioPoolEntry]` | Audio source registry (§8.5). |
|
| `audio_pool_entries` | `[AudioPoolEntry]` | Audio source registry (§8.5). |
|
||||||
| `layer_to_track_map` | `map<Uuid, u32>` | UI layer UUID → engine track id. `#[serde(default)]`. |
|
| `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
|
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
|
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.
|
embedded as-is. The entry's `relative_path` is cleared after extraction.
|
||||||
- `media/raster/<uuid>.png` — raster keyframe pixels, named by
|
- `media/raster/<uuid>.png` — raster keyframe pixels, named `media/raster/<id>.png`
|
||||||
`RasterKeyframe.media_path`.
|
after the keyframe's `id`.
|
||||||
|
|
||||||
A reader MUST NOT modify a legacy ZIP on open; it loads into memory only. **The next
|
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
|
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
|
These reflect the current reference implementation and are flagged for implementers
|
||||||
and future spec revisions:
|
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
|
- `MediaKind::Video` (`1`) is defined but never written; video media is always an
|
||||||
external file via `VideoClip.file_path`.
|
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
|
- The project `version` check is exact-match with no compatibility window; a future
|
||||||
revision should define semantic-version tolerance before bumping it.
|
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
|
## 16. Inspecting a `.beam` file
|
||||||
|
|
||||||
Because the container is plain SQLite, any `.beam` (SQLite variant) can be inspected
|
Because the container is plain SQLite, any `.beam` (SQLite variant) can be inspected
|
||||||
|
|
|
||||||
|
|
@ -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
|
/// Generate a new unique track ID
|
||||||
fn next_id(&mut self) -> TrackId {
|
fn next_id(&mut self) -> TrackId {
|
||||||
let id = self.next_track_id;
|
let id = self.next_track_id;
|
||||||
|
|
|
||||||
|
|
@ -88,15 +88,6 @@ impl Default for LargeMediaMode {
|
||||||
/// Settings for saving a project
|
/// Settings for saving a project
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct SaveSettings {
|
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).
|
/// How to store files at/above [`LARGE_MEDIA_THRESHOLD`] (pack vs reference).
|
||||||
/// `Ask` behaves as `Reference` here (safe default: don't bloat the DB).
|
/// `Ask` behaves as `Reference` here (safe default: don't bloat the DB).
|
||||||
pub large_media_mode: LargeMediaMode,
|
pub large_media_mode: LargeMediaMode,
|
||||||
|
|
@ -105,9 +96,6 @@ pub struct SaveSettings {
|
||||||
impl Default for SaveSettings {
|
impl Default for SaveSettings {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
auto_embed_threshold_bytes: 10_000_000, // 10 MB
|
|
||||||
force_embed_all: false,
|
|
||||||
force_link_all: false,
|
|
||||||
large_media_mode: LargeMediaMode::Ask,
|
large_media_mode: LargeMediaMode::Ask,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -269,7 +257,7 @@ pub fn save_beam(
|
||||||
audio_pool_entries: Vec<AudioPoolEntry>,
|
audio_pool_entries: Vec<AudioPoolEntry>,
|
||||||
layer_to_track_map: &std::collections::HashMap<uuid::Uuid, u32>,
|
layer_to_track_map: &std::collections::HashMap<uuid::Uuid, u32>,
|
||||||
thumbnail_blobs: &std::collections::HashMap<uuid::Uuid, Vec<u8>>,
|
thumbnail_blobs: &std::collections::HashMap<uuid::Uuid, Vec<u8>>,
|
||||||
_settings: &SaveSettings,
|
settings: &SaveSettings,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let fn_start = std::time::Instant::now();
|
let fn_start = std::time::Instant::now();
|
||||||
eprintln!("📊 [SAVE_BEAM] Starting save_beam() (SQLite container)...");
|
eprintln!("📊 [SAVE_BEAM] Starting save_beam() (SQLite container)...");
|
||||||
|
|
@ -349,7 +337,7 @@ pub fn save_beam(
|
||||||
// (`Ask` == reference); smaller files are always packed.
|
// (`Ask` == reference); smaller files are always packed.
|
||||||
let reference_it = entry.is_video_audio
|
let reference_it = entry.is_video_audio
|
||||||
|| (size >= LARGE_MEDIA_THRESHOLD
|
|| (size >= LARGE_MEDIA_THRESHOLD
|
||||||
&& _settings.large_media_mode != LargeMediaMode::Pack);
|
&& settings.large_media_mode != LargeMediaMode::Pack);
|
||||||
if reference_it {
|
if reference_it {
|
||||||
referenced = Some(rel.clone());
|
referenced = Some(rel.clone());
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -498,7 +486,7 @@ pub fn save_beam(
|
||||||
modified: now.clone(),
|
modified: now.clone(),
|
||||||
ui_state: document.clone(),
|
ui_state: document.clone(),
|
||||||
audio_backend: SerializedAudioBackend {
|
audio_backend: SerializedAudioBackend {
|
||||||
sample_rate: 48000, // TODO: Get from audio engine
|
sample_rate: audio_project.sample_rate(),
|
||||||
project: audio_project.clone(),
|
project: audio_project.clone(),
|
||||||
audio_pool_entries: modified_entries,
|
audio_pool_entries: modified_entries,
|
||||||
layer_to_track_map: layer_to_track_map.clone(),
|
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() {
|
for layer in document.root.children.iter_mut() {
|
||||||
if let crate::layer::AnyLayer::Raster(rl) = layer {
|
if let crate::layer::AnyLayer::Raster(rl) = layer {
|
||||||
for kf in &mut rl.keyframes {
|
for kf in &mut rl.keyframes {
|
||||||
if !kf.media_path.is_empty() {
|
// Legacy ZIP raster entries are named "media/raster/<uuid>.png",
|
||||||
match zip.by_name(&kf.media_path) {
|
// 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) => {
|
Ok(mut png_file) => {
|
||||||
let mut png_bytes = Vec::new();
|
let mut png_bytes = Vec::new();
|
||||||
let _ = png_file.read_to_end(&mut png_bytes);
|
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();
|
kf.raw_pixels = rgba.into_raw();
|
||||||
raster_load_count += 1;
|
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(_) => {
|
Err(_) => {
|
||||||
|
|
|
||||||
|
|
@ -111,8 +111,6 @@ pub struct RasterKeyframe {
|
||||||
pub time: f64,
|
pub time: f64,
|
||||||
pub width: u32,
|
pub width: u32,
|
||||||
pub height: u32,
|
pub height: u32,
|
||||||
/// ZIP-relative path: `"media/raster/<uuid>.png"`
|
|
||||||
pub media_path: String,
|
|
||||||
/// Stroke history (for potential replay / future non-destructive editing)
|
/// Stroke history (for potential replay / future non-destructive editing)
|
||||||
pub stroke_log: Vec<StrokeRecord>,
|
pub stroke_log: Vec<StrokeRecord>,
|
||||||
pub tween_after: TweenType,
|
pub tween_after: TweenType,
|
||||||
|
|
@ -157,13 +155,11 @@ impl RasterKeyframe {
|
||||||
|
|
||||||
pub fn new(time: f64, width: u32, height: u32) -> Self {
|
pub fn new(time: f64, width: u32, height: u32) -> Self {
|
||||||
let id = Uuid::new_v4();
|
let id = Uuid::new_v4();
|
||||||
let media_path = format!("media/raster/{}.png", id);
|
|
||||||
Self {
|
Self {
|
||||||
id,
|
id,
|
||||||
time,
|
time,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
media_path,
|
|
||||||
stroke_log: Vec::new(),
|
stroke_log: Vec::new(),
|
||||||
tween_after: TweenType::Hold,
|
tween_after: TweenType::Hold,
|
||||||
raw_pixels: Vec::new(),
|
raw_pixels: Vec::new(),
|
||||||
|
|
@ -264,10 +260,6 @@ impl RasterLayer {
|
||||||
.map(|pos| self.keyframes.remove(pos))
|
.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
|
// Delegate all LayerTrait methods to self.layer
|
||||||
|
|
|
||||||
|
|
@ -731,7 +731,6 @@ impl FileOperationsWorker {
|
||||||
|
|
||||||
let settings = SaveSettings {
|
let settings = SaveSettings {
|
||||||
large_media_mode,
|
large_media_mode,
|
||||||
..SaveSettings::default()
|
|
||||||
};
|
};
|
||||||
match save_beam(&path, &document, &mut audio_project, audio_pool_entries, layer_to_track_map, &thumbnail_blobs, &settings) {
|
match save_beam(&path, &document, &mut audio_project, audio_pool_entries, layer_to_track_map, &thumbnail_blobs, &settings) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue