1097 lines
47 KiB
Rust
1097 lines
47 KiB
Rust
//! 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<AudioPoolEntry>,
|
|
|
|
/// 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<uuid::Uuid, u32>,
|
|
|
|
}
|
|
|
|
/// 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<uuid::Uuid, u32>,
|
|
|
|
/// Loaded audio pool entries
|
|
pub audio_pool_entries: Vec<AudioPoolEntry>,
|
|
|
|
/// 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<uuid::Uuid, Vec<u8>>,
|
|
|
|
/// List of files that couldn't be found
|
|
pub missing_files: Vec<MissingFileInfo>,
|
|
}
|
|
|
|
/// 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<crate::beam_archive::BlobReader>,
|
|
len: u64,
|
|
}
|
|
|
|
impl std::io::Read for SyncBlobReader {
|
|
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
|
self.inner.get_mut().unwrap().read(buf)
|
|
}
|
|
}
|
|
impl std::io::Seek for SyncBlobReader {
|
|
fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result<u64> {
|
|
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<Box<dyn daw_backend::audio::MediaByteSource>, 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<dyn daw_backend::audio::AudioBlobSourceFactory> {
|
|
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<AudioPoolEntry>,
|
|
layer_to_track_map: &std::collections::HashMap<uuid::Uuid, u32>,
|
|
thumbnail_blobs: &std::collections::HashMap<uuid::Uuid, Vec<u8>>,
|
|
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<Uuid> = 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<Uuid> = None;
|
|
let mut referenced: Option<String> = 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<String> = 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<LoadedProject, String> {
|
|
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<LoadedProject, String> {
|
|
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<MissingFileInfo> = 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<LoadedProject, String> {
|
|
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/<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);
|
|
// 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<MissingFileInfo> = 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,
|
|
})
|
|
}
|