//! File I/O for .beam project files //! //! The `.beam` format is a single **SQLite database** (see [`crate::beam_archive`]): //! - `project_json` table — serialized project metadata and structure //! - `media` / `media_chunk` tables — audio and raster media (packed as chunked //! blobs, or referenced by external path) //! //! Older `.beam` files are ZIP archives; [`load_beam`] detects and reads those //! too (via [`load_beam_zip_legacy`]). Saving always writes the SQLite form, so //! opening a legacy file and saving migrates it. use crate::beam_archive::{BeamArchive, MediaKind, MediaMeta, MediaStorage, LARGE_MEDIA_THRESHOLD}; use crate::document::Document; use daw_backend::audio::pool::AudioPoolEntry; use daw_backend::audio::project::Project as AudioProject; use serde::{Deserialize, Serialize}; use std::collections::HashSet; use std::fs::File; use std::io::Read; use std::path::{Path, PathBuf}; use uuid::Uuid; use zip::ZipArchive; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; /// File format version pub const BEAM_VERSION: &str = "1.0.0"; /// Default buffer size for audio processing (512 samples = ~10.7ms at 48kHz) pub const DEFAULT_BUFFER_SIZE: usize = 512; /// Complete .beam project structure for serialization #[derive(Serialize, Deserialize)] pub struct BeamProject { /// File format version pub version: String, /// Project creation timestamp (ISO 8601) pub created: String, /// Last modified timestamp (ISO 8601) pub modified: String, /// UI state (Document from lightningbeam-core) pub ui_state: Document, /// Audio backend state pub audio_backend: SerializedAudioBackend, } /// Serialized audio backend state #[derive(Serialize, Deserialize)] pub struct SerializedAudioBackend { /// Sample rate for audio processing pub sample_rate: u32, /// Audio project (tracks, MIDI clips, etc.) pub project: AudioProject, /// Audio pool entries (metadata and media references for audio files) pub audio_pool_entries: Vec, /// Mapping from UI layer UUIDs to backend TrackIds /// Preserves the connection between UI layers and audio engine tracks across save/load #[serde(default)] pub layer_to_track_map: std::collections::HashMap, } /// How to store a media file at or above [`LARGE_MEDIA_THRESHOLD`]. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum LargeMediaMode { /// Not yet decided — prompt the user the first time a large file is imported. /// Treated as [`LargeMediaMode::Reference`] at save time. Resetting the /// preference to `Ask` re-triggers the prompt (useful for testing). Ask, /// Pack the bytes into the `.beam` container (chunked, streamed from disk). Pack, /// Keep the file external and store only a path reference. Reference, } impl Default for LargeMediaMode { fn default() -> Self { LargeMediaMode::Ask } } /// Settings for saving a project #[derive(Debug, Clone)] pub struct SaveSettings { /// 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, } impl Default for SaveSettings { fn default() -> Self { Self { large_media_mode: LargeMediaMode::Ask, } } } /// Result of loading a project pub struct LoadedProject { /// Deserialized document pub document: Document, /// Deserialized audio project pub audio_project: AudioProject, /// Mapping from UI layer UUIDs to backend TrackIds (empty for old files) pub layer_to_track_map: std::collections::HashMap, /// Loaded audio pool entries pub audio_pool_entries: Vec, /// Persisted video-thumbnail packs by clip id (opaque LBTN blobs; decoded and /// inserted into the VideoManager by the editor). Clips present here don't need /// their thumbnails regenerated on load. pub thumbnail_blobs: std::collections::HashMap>, /// List of files that couldn't be found pub missing_files: Vec, } /// Information about a missing file #[derive(Debug, Clone)] pub struct MissingFileInfo { /// Index in the audio pool pub pool_index: usize, /// Original file path pub original_path: PathBuf, /// Type of media file pub file_type: MediaFileType, } /// Type of media file #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MediaFileType { Audio, Video, Image, } /// Save a project to a `.beam` file (SQLite container). /// /// Re-saving an existing SQLite `.beam` updates it **in place** inside a single /// transaction: unchanged (large) media is never rewritten, only changed rows /// are touched, and the commit is atomic/crash-safe. A brand-new file or a /// legacy-ZIP migration is written to a temp file and atomically renamed (there /// is no large existing container to copy in that case). /// /// Audio and raster media become rows in the `media` table — packed as chunked /// blobs, or referenced by external path for files at/above /// [`LARGE_MEDIA_THRESHOLD`]. `project.json` goes in the `project_json` table. /// Whether a stored media codec is an audio format the disk reader (Symphonia) /// can stream directly from a packed blob. Video-container audio tracks and any /// unknown formats fall back to the legacy reconstitution-and-decode path. fn is_streamable_audio_codec(codec: &str) -> bool { matches!( codec.to_lowercase().as_str(), "mp3" | "flac" | "ogg" | "oga" | "wav" | "wave" | "aiff" | "aif" | "aac" | "m4a" | "opus" | "alac" | "caf" ) } /// A `Sync` wrapper over core's `BlobReader` so it satisfies Symphonia's /// `MediaSource: Send + Sync`. `BlobReader` holds a rusqlite `Connection` /// (`Send` but `!Sync`); the disk reader uses it single-threaded, so the /// hot Read/Seek path goes through `Mutex::get_mut` (no runtime locking). struct SyncBlobReader { inner: std::sync::Mutex, len: u64, } impl std::io::Read for SyncBlobReader { fn read(&mut self, buf: &mut [u8]) -> std::io::Result { self.inner.get_mut().unwrap().read(buf) } } impl std::io::Seek for SyncBlobReader { fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result { self.inner.get_mut().unwrap().seek(pos) } } impl daw_backend::audio::MediaByteSource for SyncBlobReader { fn byte_len(&self) -> u64 { self.len } } /// The host's packed-media byte-source factory: opens an independent streaming /// reader over a `.beam` container's packed audio by media id. Installed into the /// engine on load so container-packed audio streams without a full decode. #[derive(Debug)] struct BeamBlobFactory { db_path: PathBuf, } impl daw_backend::audio::AudioBlobSourceFactory for BeamBlobFactory { fn open( &self, media_id: &str, ) -> Result, String> { let id = Uuid::parse_str(media_id).map_err(|e| format!("bad media id {}: {}", media_id, e))?; let archive = BeamArchive::open(&self.db_path)?; let reader = archive.open_blob_reader(&self.db_path, id)?; let len = reader.len(); Ok(Box::new(SyncBlobReader { inner: std::sync::Mutex::new(reader), len })) } } /// Build a packed-media byte-source factory for a `.beam` file, to install into /// the engine (`EngineController::set_blob_source_factory`) before loading so its /// packed audio can be streamed. pub fn blob_source_factory( beam_path: &Path, ) -> std::sync::Arc { std::sync::Arc::new(BeamBlobFactory { db_path: beam_path.to_path_buf() }) } /// Deterministic id for the waveform-pyramid media row of audio pool entry /// `pool_index`, within a single project container. Stable across saves (so an /// in-place re-save reuses the row instead of orphaning/rewriting it) and /// independent of how the audio bytes are stored. The top 32 bits are a fixed /// "LBWF" sentinel so it can't collide with the random v4 ids used elsewhere. fn waveform_media_id(pool_index: usize) -> Uuid { const SENTINEL: u128 = 0x4C42_5746u128 << 96; // "LBWF" in the high 32 bits Uuid::from_u128(SENTINEL | (pool_index as u128)) } /// Deterministic id for the thumbnail-pack media row of a video clip. Derived from /// the clip id by XOR with a fixed constant — bijective and stable across saves, so /// it reuses the row in place, and it can't (in practice) collide with the random /// v4 ids used for other media of a different kind. fn thumbnail_media_id(clip_id: Uuid) -> Uuid { // "LBTN" repeated — moves clip ids into a distinct region of the id space. const SENTINEL: u128 = 0x4C42_544E_4C42_544E_4C42_544E_4C42_544E; Uuid::from_u128(clip_id.as_u128() ^ SENTINEL) } /// Content-hash id for an embedded font row, so identical font files dedupe to one /// row regardless of which / how many text layers use them. A 128-bit id is built /// from two salted 64-bit hashes of the bytes. fn font_media_id(bytes: &[u8]) -> Uuid { use std::hash::{Hash, Hasher}; let mut h1 = std::collections::hash_map::DefaultHasher::new(); bytes.hash(&mut h1); let mut h2 = std::collections::hash_map::DefaultHasher::new(); 0xF0E1_D2C3_B4A5_9687u64.hash(&mut h2); bytes.hash(&mut h2); let hi = h1.finish() as u128; let lo = h2.finish() as u128; Uuid::from_u128((hi << 64) | lo) } /// Derived id for a raster keyframe's low-res proxy row (distinct from the keyframe's /// own full-res `Raster` row, which is keyed by the raw keyframe id). fn raster_proxy_media_id(kf_id: Uuid) -> Uuid { // "LBPX" repeated — distinct id region from the full raster + thumbnail rows. const SENTINEL: u128 = 0x4C42_5058_4C42_5058_4C42_5058_4C42_5058; Uuid::from_u128(kf_id.as_u128() ^ SENTINEL) } pub fn save_beam( path: &Path, document: &Document, audio_project: &mut AudioProject, audio_pool_entries: Vec, layer_to_track_map: &std::collections::HashMap, thumbnail_blobs: &std::collections::HashMap>, settings: &SaveSettings, ) -> Result<(), String> { let fn_start = std::time::Instant::now(); eprintln!("📊 [SAVE_BEAM] Starting save_beam() (SQLite container)..."); let project_dir = path.parent().unwrap_or_else(|| Path::new(".")); let in_place = path.exists() && BeamArchive::is_sqlite(path); // In-place for an existing SQLite container (don't rewrite unchanged media); // temp + atomic rename for new files / legacy-ZIP migration. let tmp_path = path.with_extension("beam.tmp"); let mut archive = if in_place { BeamArchive::open(path)? } else { BeamArchive::create(&tmp_path)? }; let now = chrono::Utc::now().to_rfc3339(); let created = if in_place { archive.get_meta("created").ok().flatten().unwrap_or_else(|| now.clone()) } else { now.clone() }; let txn = archive.transaction()?; // --- audio pool entries -> media rows (packed) or external references --- let mut modified_entries = Vec::with_capacity(audio_pool_entries.len()); let mut live_media: HashSet = HashSet::new(); for entry in &audio_pool_entries { let mut e = entry.clone(); let existing_id = entry.media_id.as_ref().and_then(|s| Uuid::parse_str(s).ok()); // Already packed in this archive (in-place re-save): leave the bytes // untouched, just keep the reference. if let Some(id) = existing_id { if txn.media_exists(id)? { live_media.insert(id); e.media_id = Some(id.to_string()); e.relative_path = None; e.embedded_data = None; modified_entries.push(e); continue; } } // Otherwise resolve the source: external file (Priority 2, streamed from // disk so a huge file is never fully loaded), or embedded data (Priority 3). let meta = MediaMeta { channels: Some(entry.channels), sample_rate: Some(entry.sample_rate), ..Default::default() }; let mut wrote_packed: Option = None; let mut referenced: Option = None; if let Some(rel) = entry.relative_path.as_ref() { let full = if Path::new(rel).is_absolute() { PathBuf::from(rel) } else { project_dir.join(rel) }; // 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() .and_then(|x| x.to_str()) .unwrap_or("bin") .to_lowercase(); // Video-audio entries are always referenced (the video is already // referenced by its VideoClip; reloaded by re-probing via FFmpeg). // Otherwise large files honor the user's pack-vs-reference choice // (`Ask` == reference); smaller files are always packed. let reference_it = entry.is_video_audio || (size >= LARGE_MEDIA_THRESHOLD && settings.large_media_mode != LargeMediaMode::Pack); if reference_it { referenced = Some(rel.clone()); } else { let id = existing_id.unwrap_or_else(Uuid::new_v4); txn.put_media_packed_from_path(id, MediaKind::Audio, &codec, &full, meta)?; wrote_packed = Some(id); } } } if wrote_packed.is_none() && referenced.is_none() { if let Some(ed) = entry.embedded_data.as_ref() { if let Ok(bytes) = BASE64_STANDARD.decode(&ed.data_base64) { let id = existing_id.unwrap_or_else(Uuid::new_v4); txn.put_media_packed(id, MediaKind::Audio, &ed.format.to_lowercase(), &bytes, meta)?; wrote_packed = Some(id); } } } if let Some(id) = wrote_packed { live_media.insert(id); e.media_id = Some(id.to_string()); e.relative_path = None; e.embedded_data = None; } else if let Some(rel) = referenced { e.media_id = None; e.relative_path = Some(rel); e.embedded_data = None; } // else: nothing available — keep original references (reported missing on load) // Persist this entry's waveform pyramid (keyed by pool index, independent // of the audio storage above). Reuse the row in place on re-save. let wf_id = waveform_media_id(entry.pool_index); if let Some(blob) = entry.waveform_blob.as_ref() { txn.put_media_packed(wf_id, MediaKind::Waveform, "lbwf", blob, MediaMeta::default())?; live_media.insert(wf_id); } else if txn.media_exists(wf_id)? { // Unchanged this save — keep the stored waveform row. live_media.insert(wf_id); } modified_entries.push(e); } // --- raster keyframes -> media rows (PNG), keyed by keyframe id --- // Incremental: only (re)encode a keyframe whose pixels changed since the last save. // `kf.dirty` means "current pixels are not yet in the container" (set on any edit, // cleared on a successful save — see main.rs); a clean frame already stored is kept // in place, skipping the PNG re-encode of every resident frame on every save. // Walk ALL layers (incl. nested in groups/clips) so nested raster keyframes // are persisted too, and so `live_media` covers them — matching the load path, // which arms `needs_fault_in` recursively. Top-level-only projects are unaffected. let mut raster_count = 0usize; let mut raster_skipped = 0usize; for layer in document.all_layers() { if let crate::layer::AnyLayer::Raster(rl) = layer { for kf in &rl.keyframes { if !kf.raw_pixels.is_empty() { // Clean + already stored → keep the existing full + proxy rows untouched. if !kf.dirty && txn.media_exists(kf.id)? { live_media.insert(kf.id); let proxy_id = raster_proxy_media_id(kf.id); if txn.media_exists(proxy_id)? { live_media.insert(proxy_id); } raster_skipped += 1; continue; } let img = crate::brush_engine::image_from_raw(kf.raw_pixels.clone(), kf.width, kf.height); match crate::brush_engine::encode_png(&img) { Ok(png_bytes) => { txn.put_media_packed( kf.id, MediaKind::Raster, "png", &png_bytes, MediaMeta { width: Some(kf.width), height: Some(kf.height), ..Default::default() }, )?; live_media.insert(kf.id); raster_count += 1; } Err(e) => eprintln!("⚠️ [SAVE_BEAM] Failed to encode raster {}: {}", kf.id, e), } // Low-res proxy alongside the full PNG (shown while the full pages // in on a later load). Regenerated from the resident pixels. let proxy_id = raster_proxy_media_id(kf.id); if let Some(proxy_png) = crate::brush_engine::encode_raster_proxy_png(&kf.raw_pixels, kf.width, kf.height) { txn.put_media_packed( proxy_id, MediaKind::RasterProxy, "png", &proxy_png, MediaMeta::default(), )?; live_media.insert(proxy_id); } } else if txn.media_exists(kf.id)? { // Pixels not resident but already stored — keep both rows. live_media.insert(kf.id); let proxy_id = raster_proxy_media_id(kf.id); if txn.media_exists(proxy_id)? { live_media.insert(proxy_id); } } } } } // --- video thumbnail packs -> media rows (opaque LBTN blob), keyed by a // sentinel-derived id from the video clip id --- for clip_id in document.video_clips.keys() { let tn_id = thumbnail_media_id(*clip_id); if let Some(blob) = thumbnail_blobs.get(clip_id) { txn.put_media_packed(tn_id, MediaKind::Thumbnail, "lbtn", blob, MediaMeta::default())?; live_media.insert(tn_id); } else if txn.media_exists(tn_id)? { // Not regenerated this session — keep the stored pack. live_media.insert(tn_id); } } // --- video clips -> media rows (Video bytes), keyed by the clip id --- // Mirror the audio pack/reference decision: pack unless the file is // >= LARGE_MEDIA_THRESHOLD and the user didn't choose Pack. Packed video is // decoded by streaming from the blob (see video.rs `VideoSource` + ffmpeg-blob-io). for (clip_id, clip) in &document.video_clips { // In-place re-save: an unchanged packed video keeps its row, never re-streamed. if clip.media_id == Some(*clip_id) && txn.media_exists(*clip_id)? { live_media.insert(*clip_id); continue; } if clip.file_path.is_empty() { continue; } let full = if Path::new(&clip.file_path).is_absolute() { PathBuf::from(&clip.file_path) } else { project_dir.join(&clip.file_path) }; if !full.is_file() { continue; // source gone — reported missing on load } let size = std::fs::metadata(&full).map(|m| m.len()).unwrap_or(0); let codec = full .extension() .and_then(|x| x.to_str()) .unwrap_or("mp4") .to_lowercase(); let meta = MediaMeta { width: Some(clip.width as u32), height: Some(clip.height as u32), ..Default::default() }; let reference_it = size >= LARGE_MEDIA_THRESHOLD && settings.large_media_mode != LargeMediaMode::Pack; if reference_it { txn.put_media_referenced(*clip_id, MediaKind::Video, &codec, &clip.file_path, meta)?; } else { txn.put_media_packed_from_path(*clip_id, MediaKind::Video, &codec, &full, meta)?; // The video's audio track lives in this same blob. Point the linked // video-audio pool entry at the video's media row so its audio streams // from the blob too (instead of re-probing the external file on load). if let Some(aid) = clip.linked_audio_clip_id { if let Some(crate::clip::AudioClipType::Sampled { audio_pool_index }) = document.audio_clips.get(&aid).map(|c| &c.clip_type) { if let Some(e) = modified_entries .iter_mut() .find(|e| e.pool_index == *audio_pool_index) { e.media_id = Some(clip_id.to_string()); e.relative_path = None; e.embedded_data = None; } } } } live_media.insert(*clip_id); } // --- 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; // --- embedded fonts -> media rows (Font), keyed by content hash so identical // fonts dedupe. Embed every non-bundled family used by a text layer so the // document renders faithfully on machines lacking the font. The bundled // three ship with the app and are never embedded. --- { use crate::layer::AnyLayer; let mut families: HashSet = HashSet::new(); for layer in document.all_layers() { if let AnyLayer::Text(tl) = layer { let fam = &tl.content.font_family; if !fam.is_empty() && !crate::fonts::is_bundled(fam) { families.insert(fam.clone()); } } } for fam in families { if let Some(bytes) = crate::fonts::family_font_bytes(&fam) { let id = font_media_id(&bytes); if !txn.media_exists(id)? { txn.put_media_packed(id, MediaKind::Font, &fam, &bytes, MediaMeta::default())?; } live_media.insert(id); } } } // --- orphan cleanup: drop media for removed clips/keyframes --- let removed = txn.retain_media(&live_media)?; // --- project.json + meta --- let beam_project = BeamProject { version: BEAM_VERSION.to_string(), created: created.clone(), modified: now.clone(), ui_state: document.clone(), audio_backend: SerializedAudioBackend { sample_rate: audio_project.sample_rate(), project: audio_project.clone(), audio_pool_entries: modified_entries, layer_to_track_map: layer_to_track_map.clone(), }, }; let json = serde_json::to_string(&beam_project) .map_err(|e| format!("JSON serialization failed: {}", e))?; txn.set_project_json(&json)?; txn.set_meta("version", BEAM_VERSION)?; txn.set_meta("created", &created)?; txn.set_meta("modified", &now)?; txn.commit()?; // Close the connection before renaming (required on Windows; harmless elsewhere). drop(archive); if !in_place { std::fs::rename(&tmp_path, path) .map_err(|e| format!("Failed to finalize {:?}: {}", path, e))?; } eprintln!( "📊 [SAVE_BEAM] ✅ Saved {} audio + {} raster media ({} unchanged frames skipped), {} orphans removed, in {:.2}ms", audio_pool_entries.len(), raster_count, raster_skipped, removed, fn_start.elapsed().as_secs_f64() * 1000.0 ); Ok(()) } /// Load a project from a `.beam` file. /// /// Detects the container format: SQLite (current) or legacy ZIP, and dispatches /// accordingly. Both produce an identical [`LoadedProject`]. pub fn load_beam(path: &Path) -> Result { if BeamArchive::is_sqlite(path) { load_beam_sqlite(path) } else { load_beam_zip_legacy(path) } } /// Load a project from a SQLite `.beam` container. /// /// Phase 0 reconstitutes packed audio into each entry's `embedded_data` so the /// existing (full-decode) audio pool loader keeps working unchanged; Phase 1b /// replaces this with streaming reads via `BlobReader`. fn load_beam_sqlite(path: &Path) -> Result { let fn_start = std::time::Instant::now(); eprintln!("📊 [LOAD_BEAM] Starting load_beam() (SQLite container)..."); let archive = BeamArchive::open(path)?; // Register document-embedded fonts into the runtime font collection so text // layers referencing them render faithfully even if the host lacks the font. if let Ok(font_ids) = archive.media_ids_of_kind(MediaKind::Font) { for id in font_ids { if let Ok(bytes) = archive.read_media_full(id) { crate::fonts::register_embedded(bytes); } } } let json = archive.get_project_json()?; let beam_project: BeamProject = serde_json::from_str(&json) .map_err(|e| format!("Failed to deserialize project.json: {}", e))?; if beam_project.version != BEAM_VERSION { return Err(format!( "Unsupported file version: {} (expected {})", beam_project.version, BEAM_VERSION )); } let mut document = beam_project.ui_state; document.tempo_map_mut().rebuild_seconds(); let mut audio_project = beam_project.audio_backend.project; audio_project .rebuild_audio_graphs(DEFAULT_BUFFER_SIZE) .map_err(|e| format!("Failed to rebuild audio graphs: {}", e))?; let layer_to_track_map = beam_project.audio_backend.layer_to_track_map; // For each packed audio item: stream it (leave `embedded_data` empty so the // pool builds a Compressed placeholder backed by the blob factory) when it's a // recognized audio codec; otherwise fall back to the legacy reconstitution // (whole bytes → base64 → decode), which still covers video-container audio // tracks symphonia can't stream and any unknown formats. let mut restored_entries = Vec::with_capacity(beam_project.audio_backend.audio_pool_entries.len()); for entry in &beam_project.audio_backend.audio_pool_entries { let mut e = entry.clone(); if let Some(id) = entry.media_id.as_ref().and_then(|s| Uuid::parse_str(s).ok()) { match archive.media_info(id) { Ok(Some(info)) => { if is_streamable_audio_codec(&info.codec) { // Stream: keep media_id, no embedded bytes. The engine opens // the packed blob via the factory at activation time. e.embedded_data = None; e.relative_path = None; } else { match archive.read_media_full(id) { Ok(bytes) => { e.embedded_data = Some(daw_backend::audio::pool::EmbeddedAudioData { data_base64: BASE64_STANDARD.encode(&bytes), format: info.codec, }); e.relative_path = None; } Err(err) => eprintln!("⚠️ [LOAD_BEAM] Failed to read audio media {}: {}", id, err), } } } Ok(None) => eprintln!("⚠️ [LOAD_BEAM] Audio media {} missing from archive", id), Err(err) => eprintln!("⚠️ [LOAD_BEAM] media_info({}) failed: {}", id, err), } } // Restore this entry's persisted waveform pyramid, if present — avoids // re-decoding the source media just to redraw the overview. let wf_id = waveform_media_id(entry.pool_index); if let Ok(Some(_)) = archive.media_info(wf_id) { match archive.read_media_full(wf_id) { Ok(bytes) => e.waveform_blob = Some(bytes), Err(err) => eprintln!("⚠️ [LOAD_BEAM] Failed to read waveform {}: {}", wf_id, err), } } restored_entries.push(e); } // Raster keyframes are NOT eagerly decoded (Phase 3 paging): `raw_pixels` stays // empty and is faulted in on demand from the container's `Raster` rows via the // editor's `RasterStore` (keyed by `kf.id`). Loading a big paint project is now // instant and only the resident window lives in RAM. Mark every keyframe // `needs_fault_in` (recursively, incl. nested layers) so the renderer requests a // page-in; a freshly-created keyframe stays `false` (blank-resident, nothing to load). // Proxies (low-res, ~tens of KB each) ARE decoded eagerly so a cold scrub onto a // not-yet-paged frame shows the proxy instantly instead of flashing blank. let mut raster_load_count = 0usize; let mut proxy_load_count = 0usize; for layer in document.all_layers_mut() { if let crate::layer::AnyLayer::Raster(rl) = layer { for kf in &mut rl.keyframes { kf.needs_fault_in = true; raster_load_count += 1; let proxy_id = raster_proxy_media_id(kf.id); if let Ok(Some(_)) = archive.media_info(proxy_id) { if let Ok(bytes) = archive.read_media_full(proxy_id) { if let Ok(img) = crate::brush_engine::decode_png(&bytes) { let (w, h) = (img.width(), img.height()); kf.proxy = Some(crate::raster_layer::RasterProxy { width: w, height: h, pixels: img.into_raw(), }); proxy_load_count += 1; } } } } } } let _ = proxy_load_count; // Image-asset bytes are NOT eagerly read (Phase 4 Tier 1 paging): `ImageAsset.data` // stays empty and the renderer's ImageCache pages bytes from the container on a // decode miss (keyed by asset id). Old base64 projects keep their deserialized // `data` (no container row). Loading is instant; only rendered images touch disk. // Missing external files (referenced entries whose file no longer exists). let project_dir = path.parent().unwrap_or_else(|| Path::new(".")); let mut missing_files: Vec = restored_entries .iter() .enumerate() .filter_map(|(idx, entry)| { if entry.embedded_data.is_none() && entry.media_id.is_none() { if let Some(rel) = entry.relative_path.as_ref() { let full = if Path::new(rel).is_absolute() { PathBuf::from(rel) } else { project_dir.join(rel) }; if !full.exists() { return Some(MissingFileInfo { pool_index: idx, original_path: full, file_type: MediaFileType::Audio, }); } } } None }) .collect(); // Resolve packed/referenced video bytes per clip (mirrors audio resolution). // A `MediaKind::Video` row keyed by the clip id means the bytes are in the // container; the decoder streams from the blob. Referenced rows update the // external path and report a missing source. Old projects (no Video row) stay // referenced via their existing `file_path`. for (clip_id, clip) in document.video_clips.iter_mut() { match archive.media_info(*clip_id) { Ok(Some(info)) if info.kind == MediaKind::Video => match info.storage { MediaStorage::Packed => { clip.media_id = Some(*clip_id); } MediaStorage::Referenced => { clip.media_id = None; if let Some(p) = info.ext_path.clone() { clip.file_path = p; } let full = if Path::new(&clip.file_path).is_absolute() { PathBuf::from(&clip.file_path) } else { project_dir.join(&clip.file_path) }; if !clip.file_path.is_empty() && !full.exists() { missing_files.push(MissingFileInfo { pool_index: 0, original_path: full, file_type: MediaFileType::Video, }); } } }, _ => { clip.media_id = None; } } } // Persisted video thumbnail packs (opaque LBTN blobs), keyed by clip id. The // editor decodes + inserts them and skips regeneration for these clips. let mut thumbnail_blobs = std::collections::HashMap::new(); for clip_id in document.video_clips.keys() { let tn_id = thumbnail_media_id(*clip_id); if let Ok(Some(info)) = archive.media_info(tn_id) { if info.kind == MediaKind::Thumbnail { match archive.read_media_full(tn_id) { Ok(bytes) => { thumbnail_blobs.insert(*clip_id, bytes); } Err(e) => eprintln!("⚠️ [LOAD_BEAM] Failed to read thumbnails for {}: {}", clip_id, e), } } } } eprintln!( "📊 [LOAD_BEAM] ✅ Loaded {} audio entries, {} raster frames, {} thumbnail packs in {:.2}ms", restored_entries.len(), raster_load_count, thumbnail_blobs.len(), fn_start.elapsed().as_secs_f64() * 1000.0 ); Ok(LoadedProject { document, audio_project, layer_to_track_map, audio_pool_entries: restored_entries, thumbnail_blobs, missing_files, }) } /// Load a project from a legacy ZIP `.beam` archive (pre-SQLite format). /// Retained for backward compatibility; saving converts to SQLite. fn load_beam_zip_legacy(path: &Path) -> Result { let fn_start = std::time::Instant::now(); eprintln!("📊 [LOAD_BEAM] Starting load_beam() (legacy ZIP)..."); // 1. Open ZIP archive let step1_start = std::time::Instant::now(); let file = File::open(path) .map_err(|e| format!("Failed to open file: {}", e))?; let mut zip = ZipArchive::new(file) .map_err(|e| format!("Failed to open ZIP archive: {}", e))?; eprintln!("📊 [LOAD_BEAM] Step 1: Open ZIP archive took {:.2}ms", step1_start.elapsed().as_secs_f64() * 1000.0); // 2. Read project.json let step2_start = std::time::Instant::now(); let mut project_file = zip.by_name("project.json") .map_err(|e| format!("Failed to find project.json in archive: {}", e))?; let mut json_data = String::new(); project_file.read_to_string(&mut json_data) .map_err(|e| format!("Failed to read project.json: {}", e))?; eprintln!("📊 [LOAD_BEAM] Step 2: Read project.json ({} bytes) took {:.2}ms", json_data.len(), step2_start.elapsed().as_secs_f64() * 1000.0); // 3. Deserialize BeamProject let step3_start = std::time::Instant::now(); let beam_project: BeamProject = serde_json::from_str(&json_data) .map_err(|e| format!("Failed to deserialize project.json: {}", e))?; eprintln!("📊 [LOAD_BEAM] Step 3: Deserialize BeamProject took {:.2}ms", step3_start.elapsed().as_secs_f64() * 1000.0); // 4. Check version compatibility if beam_project.version != BEAM_VERSION { return Err(format!( "Unsupported file version: {} (expected {})", beam_project.version, BEAM_VERSION )); } // 5. Extract document and audio backend state let step5_start = std::time::Instant::now(); let mut document = beam_project.ui_state; // Rebuild derived seconds cache on all TempoMap entries after deserialization. document.tempo_map_mut().rebuild_seconds(); let mut audio_project = beam_project.audio_backend.project; let audio_pool_entries = beam_project.audio_backend.audio_pool_entries; let layer_to_track_map = beam_project.audio_backend.layer_to_track_map; eprintln!("📊 [LOAD_BEAM] Step 5: Extract document and audio state took {:.2}ms", step5_start.elapsed().as_secs_f64() * 1000.0); // 6. Rebuild AudioGraphs from presets let step6_start = std::time::Instant::now(); audio_project.rebuild_audio_graphs(DEFAULT_BUFFER_SIZE) .map_err(|e| format!("Failed to rebuild audio graphs: {}", e))?; eprintln!("📊 [LOAD_BEAM] Step 6: Rebuild AudioGraphs took {:.2}ms", step6_start.elapsed().as_secs_f64() * 1000.0); // 7. Extract embedded audio files from ZIP and restore to entries let step7_start = std::time::Instant::now(); drop(project_file); // Close project.json file handle let mut restored_entries = Vec::new(); let mut flac_decode_time = 0.0; for entry in &audio_pool_entries { let mut restored_entry = entry.clone(); // Check if this entry has a file in the ZIP (relative_path starts with "media/audio/") if let Some(ref rel_path) = entry.relative_path { if rel_path.starts_with("media/audio/") { // Extract file from ZIP match zip.by_name(rel_path) { Ok(mut audio_file) => { let mut audio_bytes = Vec::new(); audio_file.read_to_end(&mut audio_bytes) .map_err(|e| format!("Failed to read {} from ZIP: {}", rel_path, e))?; // Determine format from filename let format = rel_path.split('.').last() .unwrap_or("flac") .to_string(); // For lossless formats, decode back to PCM f32 samples // For lossy formats, store the original bytes let embedded_data = if format == "flac" { // Decode FLAC to PCM f32 samples let flac_decode_start = std::time::Instant::now(); let cursor = std::io::Cursor::new(&audio_bytes); let mut reader = claxon::FlacReader::new(cursor) .map_err(|e| format!("Failed to create FLAC reader: {:?}", e))?; let stream_info = reader.streaminfo(); let bits_per_sample = stream_info.bits_per_sample; let max_value = (1i64 << (bits_per_sample - 1)) as f32; // Read all samples and convert to f32 let mut samples_f32 = Vec::new(); for sample_result in reader.samples() { let sample = sample_result .map_err(|e| format!("Failed to read FLAC sample: {:?}", e))?; samples_f32.push(sample as f32 / max_value); } // Encode f32 samples as a proper WAV file (with RIFF header) let channels = entry.channels; let sample_rate = entry.sample_rate; let num_samples = samples_f32.len(); let bytes_per_sample = 4u32; // 32-bit float let data_size = num_samples * bytes_per_sample as usize; let file_size = 36 + data_size; let mut wav_data = Vec::with_capacity(44 + data_size); wav_data.extend_from_slice(b"RIFF"); wav_data.extend_from_slice(&(file_size as u32).to_le_bytes()); wav_data.extend_from_slice(b"WAVE"); wav_data.extend_from_slice(b"fmt "); wav_data.extend_from_slice(&16u32.to_le_bytes()); wav_data.extend_from_slice(&3u16.to_le_bytes()); // IEEE float wav_data.extend_from_slice(&(channels as u16).to_le_bytes()); wav_data.extend_from_slice(&sample_rate.to_le_bytes()); wav_data.extend_from_slice(&(sample_rate * channels * bytes_per_sample).to_le_bytes()); wav_data.extend_from_slice(&((channels * bytes_per_sample) as u16).to_le_bytes()); wav_data.extend_from_slice(&32u16.to_le_bytes()); wav_data.extend_from_slice(b"data"); wav_data.extend_from_slice(&(data_size as u32).to_le_bytes()); for &sample in &samples_f32 { wav_data.extend_from_slice(&sample.to_le_bytes()); } flac_decode_time += flac_decode_start.elapsed().as_secs_f64() * 1000.0; Some(daw_backend::audio::pool::EmbeddedAudioData { data_base64: BASE64_STANDARD.encode(&wav_data), format: "wav".to_string(), }) } else { // Lossy format - store as-is Some(daw_backend::audio::pool::EmbeddedAudioData { data_base64: BASE64_STANDARD.encode(&audio_bytes), format: format.clone(), }) }; restored_entry.embedded_data = embedded_data; restored_entry.relative_path = None; // Clear ZIP path } Err(_) => { // File not found in ZIP, treat as external reference } } } } restored_entries.push(restored_entry); } eprintln!("📊 [LOAD_BEAM] Step 7: Extract embedded audio ({} entries) took {:.2}ms", audio_pool_entries.len(), step7_start.elapsed().as_secs_f64() * 1000.0); if flac_decode_time > 0.0 { eprintln!("📊 [LOAD_BEAM] - FLAC decoding: {:.2}ms", flac_decode_time); } // 7b. Load raster layer PNG buffers from ZIP let step7b_start = std::time::Instant::now(); let mut raster_load_count = 0usize; for layer in document.root.children.iter_mut() { if let crate::layer::AnyLayer::Raster(rl) = layer { for kf in &mut rl.keyframes { // Legacy ZIP raster entries are named "media/raster/.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); // Decode PNG into raw RGBA pixels for fast in-memory access match crate::brush_engine::decode_png(&png_bytes) { Ok(rgba) => { kf.raw_pixels = rgba.into_raw(); raster_load_count += 1; } Err(e) => eprintln!("⚠️ [LOAD_BEAM] Failed to decode raster PNG {}: {}", entry_path, e), } } Err(_) => { // Keyframe PNG not in ZIP yet (new keyframe); leave raw_pixels empty } } } } } } eprintln!("📊 [LOAD_BEAM] Step 7b: Load {} raster PNG buffers took {:.2}ms", raster_load_count, step7b_start.elapsed().as_secs_f64() * 1000.0); // 8. Check for missing external files // An entry is missing if it has a relative_path (external reference) // but no embedded_data and the file doesn't exist let step8_start = std::time::Instant::now(); let project_dir = path.parent().unwrap_or_else(|| Path::new(".")); let missing_files: Vec = restored_entries .iter() .enumerate() .filter_map(|(idx, entry)| { // Check if this entry references an external file that doesn't exist if entry.embedded_data.is_none() { if let Some(ref rel_path) = entry.relative_path { let full_path = project_dir.join(rel_path); if !full_path.exists() { return Some(MissingFileInfo { pool_index: idx, original_path: full_path, file_type: MediaFileType::Audio, }); } } } None }) .collect(); eprintln!("📊 [LOAD_BEAM] Step 8: Check missing files took {:.2}ms", step8_start.elapsed().as_secs_f64() * 1000.0); eprintln!("📊 [LOAD_BEAM] ✅ Total load_beam() time: {:.2}ms", fn_start.elapsed().as_secs_f64() * 1000.0); Ok(LoadedProject { document, audio_project, layer_to_track_map, audio_pool_entries: restored_entries, thumbnail_blobs: std::collections::HashMap::new(), // legacy ZIP has no thumbnail packs missing_files, }) }