Phase 3.5b: persist image assets in the .beam container

Image asset bytes are now stored as MediaKind::ImageAsset rows in the SQLite
container (chunked, kept-in-place on re-save) instead of base64-embedded in the
project JSON — the pageable storage Phase 4 needs.

- ImageAsset.data is `#[serde(default, skip_serializing)]`: never written to JSON,
  but still deserialized for old projects (base64) which then migrate to the
  container on the next save.
- save_beam writes each asset's bytes (keyed by asset id; ext from the source path),
  keeping an existing row when bytes aren't resident; live_media covers them so orphan
  cleanup doesn't drop them.
- load_beam_sqlite eager-reads the bytes back into `data` (Phase 4 makes this lazy +
  LRU). Old base64 projects keep their JSON-deserialized data (no container row).
This commit is contained in:
Skyler Lehmkuhl 2026-06-21 00:44:04 -04:00
parent aad2d5c515
commit 2f3b0d7790
2 changed files with 44 additions and 3 deletions

View File

@ -263,9 +263,11 @@ pub struct ImageAsset {
/// Image height in pixels
pub height: u32,
/// Embedded image data (for project portability)
/// If None, the image will be loaded from path when needed
#[serde(skip_serializing_if = "Option::is_none")]
/// Raw image file bytes. NOT serialized to project JSON — persisted as a
/// `MediaKind::ImageAsset` row in the `.beam` container (chunked, pageable) and
/// read back on load. `default` so new projects (bytes in the container, not JSON)
/// deserialize; old projects with base64-embedded `data` still load via deserialize.
#[serde(default, skip_serializing)]
pub data: Option<Vec<u8>>,
/// Folder this asset belongs to (None = root of category)

View File

@ -462,6 +462,32 @@ pub fn save_beam(
}
}
// --- image assets -> media rows (original file bytes), keyed by asset id ---
let mut image_count = 0usize;
for (id, asset) in &document.image_assets {
if let Some(ref data) = asset.data {
let ext = asset
.path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("img")
.to_lowercase();
txn.put_media_packed(
*id,
MediaKind::ImageAsset,
&ext,
data,
MediaMeta { width: Some(asset.width), height: Some(asset.height), ..Default::default() },
)?;
live_media.insert(*id);
image_count += 1;
} else if txn.media_exists(*id)? {
// Bytes not resident (paged out) but already stored — keep the row.
live_media.insert(*id);
}
}
let _ = image_count;
// --- orphan cleanup: drop media for removed clips/keyframes ---
let removed = txn.retain_media(&live_media)?;
@ -625,6 +651,19 @@ fn load_beam_sqlite(path: &Path) -> Result<LoadedProject, String> {
}
let _ = proxy_load_count;
// Eager-read image-asset bytes from the container into `data` (Phase 4 will make
// this lazy + LRU). Old projects keep their base64-deserialized `data` and have no
// container row — those are skipped (`data` already Some).
for (id, asset) in document.image_assets.iter_mut() {
if asset.data.is_none() {
if let Ok(Some(_)) = archive.media_info(*id) {
if let Ok(bytes) = archive.read_media_full(*id) {
asset.data = Some(bytes);
}
}
}
}
// Missing external files (referenced entries whose file no longer exists).
let project_dir = path.parent().unwrap_or_else(|| Path::new("."));
let missing_files: Vec<MissingFileInfo> = restored_entries