Phase 4 Tier 1: lazy image-asset bytes paged from the container

Project load no longer eager-reads all image bytes — `ImageAsset.data` stays empty
and the renderer's ImageCache pages compressed bytes from the `.beam` on a decode
miss (read_packed_media_readonly by asset id), decoding into the byte-bounded Tier-2
cache. Result: instant load, and compressed bytes don't accumulate on the heap.

- ImageCache: `container_path` + `resolve_bytes` (asset.data if resident — fresh
  import or old base64 project — else page from the container); decoders take `&[u8]`
  and use the decoded dimensions.
- Container path threaded App.current_file_path → SharedPaneState → VelloRenderContext,
  set on the cache each prepare.
- load_beam_sqlite drops the 3.5b eager read.

(Refinement: a persistent read connection instead of open-per-miss.)
This commit is contained in:
Skyler Lehmkuhl 2026-06-21 01:08:54 -04:00
parent d05cbfcde5
commit 3d0a334014
5 changed files with 50 additions and 28 deletions

View File

@ -651,18 +651,10 @@ 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);
}
}
}
}
// 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("."));

View File

@ -36,6 +36,9 @@ pub struct ImageCache {
/// session uses one path) and the running total.
sizes: HashMap<Uuid, usize>,
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<std::path::PathBuf>,
}
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<std::path::PathBuf>) {
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<std::borrow::Cow<'a, [u8]>> {
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<tiny_skia::Pixmap> {
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<tiny_skia::Pixmap> {
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<tiny_skia::Pixmap> {
Some(pixmap)
}
/// Decode an image asset to peniko ImageBrush
fn decode_image_asset(asset: &ImageAsset) -> Option<ImageBrush> {
// 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<ImageBrush> {
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))

View File

@ -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;

View File

@ -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<std::path::PathBuf>,
/// 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.

View File

@ -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<std::path::PathBuf>,
/// Active layer for tool operations
active_layer_id: Option<uuid::Uuid>,
/// 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(),