diff --git a/lightningbeam-ui/lightningbeam-core/src/file_io.rs b/lightningbeam-ui/lightningbeam-core/src/file_io.rs index 41b7fff..c272537 100644 --- a/lightningbeam-ui/lightningbeam-core/src/file_io.rs +++ b/lightningbeam-ui/lightningbeam-core/src/file_io.rs @@ -651,18 +651,10 @@ fn load_beam_sqlite(path: &Path) -> Result { } 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); - } - } - } - } + // 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(".")); diff --git a/lightningbeam-ui/lightningbeam-core/src/renderer.rs b/lightningbeam-ui/lightningbeam-core/src/renderer.rs index fcfff23..9b851f7 100644 --- a/lightningbeam-ui/lightningbeam-core/src/renderer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/renderer.rs @@ -36,6 +36,9 @@ pub struct ImageCache { /// session uses one path) and the running total. sizes: HashMap, bytes: usize, + /// `.beam` container path for lazily loading compressed `ImageAsset` bytes on a + /// decode miss (Tier 1 paging) when `asset.data` isn't resident. + container_path: Option, } impl ImageCache { @@ -50,9 +53,29 @@ impl ImageCache { lru: Vec::new(), sizes: HashMap::new(), bytes: 0, + container_path: None, } } + /// Set the `.beam` container path used to lazily load image bytes that aren't + /// resident in `asset.data` (Tier 1 paging). Cheap to call each frame. + pub fn set_container_path(&mut self, path: Option) { + self.container_path = path; + } + + /// Resolve an asset's compressed bytes: prefer the resident `asset.data` (imported + /// this session, or an old base64 project), else page from the container. + fn resolve_bytes<'a>(&self, asset: &'a ImageAsset) -> Option> { + if let Some(d) = &asset.data { + return Some(std::borrow::Cow::Borrowed(d.as_slice())); + } + let path = self.container_path.as_ref()?; + crate::beam_archive::read_packed_media_readonly(path, asset.id) + .ok() + .flatten() + .map(std::borrow::Cow::Owned) + } + /// Mark `id` (size `size` bytes) as most-recently-used; evict LRU entries over budget. fn touch(&mut self, id: Uuid, size: usize) { if !self.sizes.contains_key(&id) { @@ -82,8 +105,9 @@ impl ImageCache { return Some(cached); } - // Decode and cache - let image = decode_image_asset(asset)?; + // Decode and cache (bytes from asset.data or paged from the container). + let bytes = self.resolve_bytes(asset)?; + let image = decode_image_brush(&bytes)?; let arc_image = Arc::new(image); self.cache.insert(asset.id, Arc::clone(&arc_image)); self.touch(asset.id, size); @@ -98,7 +122,8 @@ impl ImageCache { return Some(cached); } - let pixmap = decode_image_to_pixmap(asset)?; + let bytes = self.resolve_bytes(asset)?; + let pixmap = decode_image_to_pixmap(&bytes)?; let arc = Arc::new(pixmap); self.cpu_cache.insert(asset.id, Arc::clone(&arc)); self.touch(asset.id, size); @@ -133,12 +158,12 @@ impl Default for ImageCache { } } -/// Decode an image asset to a premultiplied tiny-skia Pixmap (CPU render path). -fn decode_image_to_pixmap(asset: &ImageAsset) -> Option { - let data = asset.data.as_ref()?; +/// Decode image bytes to a premultiplied tiny-skia Pixmap (CPU render path). +fn decode_image_to_pixmap(data: &[u8]) -> Option { let img = image::load_from_memory(data).ok()?; let rgba = img.to_rgba8(); - let mut pixmap = tiny_skia::Pixmap::new(asset.width, asset.height)?; + let (iw, ih) = rgba.dimensions(); + let mut pixmap = tiny_skia::Pixmap::new(iw, ih)?; for (dst, src) in pixmap.pixels_mut().iter_mut().zip(rgba.pixels()) { let [r, g, b, a] = src.0; // Convert straight alpha (image crate output) to premultiplied (tiny-skia internal format) @@ -152,21 +177,17 @@ fn decode_image_to_pixmap(asset: &ImageAsset) -> Option { Some(pixmap) } -/// Decode an image asset to peniko ImageBrush -fn decode_image_asset(asset: &ImageAsset) -> Option { - // Get the raw file data - let data = asset.data.as_ref()?; - - // Decode using the image crate +/// Decode image bytes to a peniko ImageBrush (GPU render path). +fn decode_image_brush(data: &[u8]) -> Option { let img = image::load_from_memory(data).ok()?; let rgba = img.to_rgba8(); + let (iw, ih) = rgba.dimensions(); - // Create peniko ImageData then ImageBrush let image_data = ImageData { data: Blob::from(rgba.into_raw()), format: ImageFormat::Rgba8, - width: asset.width, - height: asset.height, + width: iw, + height: ih, alpha_type: ImageAlphaType::Alpha, }; Some(ImageBrush::new(image_data)) diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index f811070..139630c 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -6472,6 +6472,7 @@ impl eframe::App for EditorApp { // Create render context let mut ctx = RenderContext { shared: panes::SharedPaneState { + container_path: self.current_file_path.clone(), onion: { // Onion skinning is disabled during playback. let mut o = self.onion_skin; diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index 2fa485a..66ba40d 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -175,6 +175,9 @@ impl OnionSkinSettings { } pub struct SharedPaneState<'a> { + /// Current `.beam` container path (for lazily paging image-asset bytes in the + /// renderer's ImageCache). `None` before the project is first saved/loaded. + pub container_path: Option, /// Effective onion-skin settings (already gated to off during playback by main.rs). pub onion: OnionSkinSettings, /// The raw onion-skin settings, mutable — edited by the Info Panel's controls. diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index a428306..adac5b7 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -437,6 +437,8 @@ struct VelloRenderContext { tool_state: lightningbeam_core::tool::ToolState, /// Onion-skinning settings (already gated off during playback). onion: crate::panes::OnionSkinSettings, + /// `.beam` container path for lazily paging image-asset bytes in the ImageCache. + container_path: Option, /// Active layer for tool operations active_layer_id: Option, /// Delta for drag preview (world space) @@ -975,6 +977,8 @@ impl egui_wgpu::CallbackTrait for VelloCallback { let _t_after_gpu_dispatches = std::time::Instant::now(); let mut image_cache = shared.image_cache.lock().unwrap(); + // Let the cache page image bytes from the project container on a decode miss. + image_cache.set_container_path(self.ctx.container_path.clone()); let composite_result = if shared.is_cpu_renderer { lightningbeam_core::renderer::render_document_for_compositing_cpu( @@ -12073,6 +12077,7 @@ impl PaneRenderer for StagePane { document: shared.action_executor.document_arc(), tool_state: shared.tool_state.clone(), onion: shared.onion, + container_path: shared.container_path.clone(), active_layer_id: *shared.active_layer_id, drag_delta, selection: shared.selection.clone(),