mirror of
https://github.com/penpot/penpot.git
synced 2026-08-13 16:28:59 +00:00
⚡ Keep fill-image display GPU tier lazy for deep zoom
Store encoded media in RAM, upload a shape-sized display texture eagerly, and only promote the native full texture when zoom needs it so oversized fillImages do not saturate integrated GPUs.
This commit is contained in:
parent
0d8981f0ce
commit
f8825af25e
@ -874,30 +874,18 @@
|
||||
nil)
|
||||
|
||||
|
||||
(defn- get-texture-id-for-gl-object
|
||||
"Registers a WebGL texture with Emscripten's GL object system and returns its ID"
|
||||
[texture]
|
||||
(let [gl-obj (unchecked-get wasm/internal-module "GL")
|
||||
textures (.-textures ^js gl-obj)
|
||||
new-id (.getNewId ^js gl-obj textures)]
|
||||
(aset textures new-id texture)
|
||||
new-id))
|
||||
|
||||
(defn- svg-blob?
|
||||
[^js blob]
|
||||
(str/starts-with? (.-type blob) "image/svg"))
|
||||
|
||||
(defn- store-svg-image
|
||||
"Sends raw SVG bytes to WASM so Skia parses and rasterizes them there.
|
||||
Browsers reject SVG blobs in `createImageBitmap`, so SVGs skip the
|
||||
shared-texture path."
|
||||
(defn- store-image-bytes
|
||||
"Sends encoded image bytes to WASM. Rust keeps them in RAM and builds the
|
||||
GPU display tier (shape @ 100% zoom); full native stays lazy for deep zoom.
|
||||
Used for both SVG and raster fills so we never upload a full-res GL texture
|
||||
from JS."
|
||||
[shape-id image-id thumbnail? ^js blob]
|
||||
(-> (.arrayBuffer blob)
|
||||
(p/then
|
||||
(fn [buffer]
|
||||
(let [image-bytes (js/Uint8Array. buffer)
|
||||
;; Header: 16 bytes shape uuid + 16 bytes image uuid
|
||||
;; + 4 bytes thumbnail flag, then the raw SVG payload.
|
||||
;; + 4 bytes thumbnail flag, then the encoded payload.
|
||||
offset (mem/alloc (+ 36 (.-byteLength image-bytes)))
|
||||
heap (mem/get-heap-u8)
|
||||
dview (mem/get-data-view)]
|
||||
@ -909,46 +897,9 @@
|
||||
(h/call wasm/internal-module "_store_image")
|
||||
true)))))
|
||||
|
||||
(defn- store-image-texture
|
||||
"Creates a WebGL texture from a decoded image and passes the texture ID to
|
||||
WASM. This avoids decoding the image twice (once in browser, once in WASM)."
|
||||
[shape-id image-id thumbnail? img]
|
||||
(when-let [gl (webgl/get-webgl-context)]
|
||||
(let [texture (webgl/create-webgl-texture-from-image gl img)
|
||||
texture-id (get-texture-id-for-gl-object texture)
|
||||
width (.-width ^js img)
|
||||
height (.-height ^js img)
|
||||
;; Header: 32 bytes (2 UUIDs) + 4 bytes (thumbnail)
|
||||
;; + 4 bytes (texture ID) + 8 bytes (dimensions)
|
||||
total-bytes 48
|
||||
offset (mem/alloc->offset-32 total-bytes)
|
||||
heap32 (mem/get-heap-u32)]
|
||||
|
||||
;; 1. Set shape id (offset + 0 to offset + 3)
|
||||
(mem.h32/write-uuid offset heap32 shape-id)
|
||||
|
||||
;; 2. Set image id (offset + 4 to offset + 7)
|
||||
(mem.h32/write-uuid (+ offset 4) heap32 image-id)
|
||||
|
||||
;; 3. Set thumbnail flag as u32 (offset + 8)
|
||||
(aset heap32 (+ offset 8) (if thumbnail? 1 0))
|
||||
|
||||
;; 4. Set texture ID (offset + 9)
|
||||
(aset heap32 (+ offset 9) texture-id)
|
||||
|
||||
;; 5. Set width (offset + 10)
|
||||
(aset heap32 (+ offset 10) width)
|
||||
|
||||
;; 6. Set height (offset + 11)
|
||||
(aset heap32 (+ offset 11) height)
|
||||
|
||||
(h/call wasm/internal-module "_store_image_from_texture")
|
||||
true)))
|
||||
|
||||
(defn- fetch-image
|
||||
"Loads an image and hands it to WASM. Raster images are decoded by the
|
||||
browser and shared as a WebGL texture; SVG images are sent as raw bytes
|
||||
so Skia rasterizes them."
|
||||
"Loads an image and hands encoded bytes to WASM (`_store_image`). LOD /
|
||||
display-vs-full GPU tiers are handled entirely in Rust."
|
||||
[shape-id image-id thumbnail?]
|
||||
(let [url (cf/resolve-file-media {:id image-id} thumbnail?)]
|
||||
{:key url
|
||||
@ -959,11 +910,7 @@
|
||||
(p/then (fn [^js response] (.blob response)))))
|
||||
(rx/mapcat
|
||||
(fn [^js blob]
|
||||
(rx/from
|
||||
(if (svg-blob? blob)
|
||||
(store-svg-image shape-id image-id thumbnail? blob)
|
||||
(p/then (js/createImageBitmap blob)
|
||||
(partial store-image-texture shape-id image-id thumbnail?))))))
|
||||
(rx/from (store-image-bytes shape-id image-id thumbnail? blob))))
|
||||
(rx/catch
|
||||
(fn [cause]
|
||||
(log/error :hint "Could not fetch image"
|
||||
@ -1005,7 +952,7 @@
|
||||
(defn set-shape-fills
|
||||
[shape-id fills thumbnail?]
|
||||
;; Record write is shared with the headless exporter; the image fetch below is
|
||||
;; browser-only (WebGL textures).
|
||||
;; browser-only (bytes → Rust ImageStore).
|
||||
(when-let [fills (props/write-shape-fills! fills)]
|
||||
(keep (fn [id]
|
||||
(let [buffer (uuid/get-u32 id)
|
||||
@ -1022,7 +969,7 @@
|
||||
(defn set-shape-strokes
|
||||
[shape-id strokes thumbnail?]
|
||||
;; Record write is shared with the headless exporter; the image fetch below is
|
||||
;; browser-only (WebGL textures).
|
||||
;; browser-only (bytes → Rust ImageStore).
|
||||
(keep (fn [image-id]
|
||||
(let [buffer (uuid/get-u32 image-id)
|
||||
cached-image? (h/call wasm/internal-module "_is_image_cached"
|
||||
|
||||
@ -4,7 +4,7 @@ pub mod filters;
|
||||
mod fonts;
|
||||
pub mod gpu_state;
|
||||
pub mod grid_layout;
|
||||
mod images;
|
||||
pub mod images;
|
||||
mod options;
|
||||
pub mod pdf;
|
||||
pub mod raster;
|
||||
|
||||
@ -88,7 +88,15 @@ fn draw_image_fill(
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(image) = get_resources().images.get(&image_fill.id()) else {
|
||||
let view_scale = render_state.get_view_scale();
|
||||
let dpr = render_state.options.dpr.max(1.0);
|
||||
let display_side = super::images::shape_side_px(&shape.selrect, dpr);
|
||||
let needed_side = super::images::shape_side_px(&shape.selrect, view_scale);
|
||||
let Some(image) = get_resources().images.get_for_draw(
|
||||
&image_fill.id(),
|
||||
display_side,
|
||||
needed_side,
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
|
||||
|
||||
@ -55,8 +55,44 @@ pub fn get_source_rect(size: ISize, container: &MathRect, image_fill: &ImageFill
|
||||
MathRect::from_xywh(source_x, source_y, source_width, source_height)
|
||||
}
|
||||
|
||||
/// Longest design-space side of a shape, in device pixels at the given scale
|
||||
/// (`zoom × dpr` for needed; `dpr` alone for the 100%-zoom display tier).
|
||||
pub fn shape_side_px(selrect: &MathRect, scale: f32) -> i32 {
|
||||
let side = selrect.width().max(selrect.height()).max(1.0);
|
||||
(side * scale.max(1e-6)).ceil().max(1.0) as i32
|
||||
}
|
||||
|
||||
fn rgba_bytes(image: &Image) -> usize {
|
||||
let d = image.dimensions();
|
||||
(d.width.max(0) as usize) * (d.height.max(0) as usize) * 4
|
||||
}
|
||||
|
||||
fn fit_size(native: ISize, max_side: i32) -> ISize {
|
||||
let longest = native.width.max(native.height).max(1);
|
||||
if max_side <= 0 || longest <= max_side {
|
||||
return native;
|
||||
}
|
||||
let scale = max_side as f32 / longest as f32;
|
||||
ISize::new(
|
||||
(native.width as f32 * scale).round().max(1.0) as i32,
|
||||
(native.height as f32 * scale).round().max(1.0) as i32,
|
||||
)
|
||||
}
|
||||
|
||||
/// Encoded raster kept in RAM, with optional GPU tiers:
|
||||
/// - `display`: shape @ 100% zoom (eager, small)
|
||||
/// - `full`: native resolution (lazy, for deep zoom)
|
||||
struct RasterStored {
|
||||
raw: Vec<u8>,
|
||||
native: ISize,
|
||||
display: Option<Image>,
|
||||
display_side: i32,
|
||||
full: Option<Image>,
|
||||
}
|
||||
|
||||
enum StoredImage {
|
||||
Raw(Vec<u8>),
|
||||
Raster(RasterStored),
|
||||
/// Legacy / thumbnail path: a single GPU texture (often from a shared GL tex).
|
||||
Gpu(Image),
|
||||
Svg {
|
||||
dom: skia::svg::Dom,
|
||||
@ -65,12 +101,13 @@ enum StoredImage {
|
||||
// exports). The shape fill path draws the DOM directly instead.
|
||||
raster: Option<Image>,
|
||||
},
|
||||
/// Encoded bytes not yet classified (decode failed at add time).
|
||||
Raw(Vec<u8>),
|
||||
}
|
||||
|
||||
struct StoredEntry {
|
||||
image: StoredImage,
|
||||
/// Approximate retained cost: encoded byte length (raw/svg) or the
|
||||
/// decoded RGBA size for images registered from a GL texture.
|
||||
/// Approximate retained cost: encoded bytes + resident GPU RGBA.
|
||||
bytes: usize,
|
||||
/// LRU tick; `Cell` so read paths can touch it without `&mut self`.
|
||||
last_used: Cell<u64>,
|
||||
@ -128,19 +165,36 @@ fn create_image_from_gl_texture(
|
||||
Ok(image)
|
||||
}
|
||||
|
||||
// Decode and upload to GPU
|
||||
fn decode_image(context: &mut Box<DirectContext>, raw_data: &[u8]) -> Option<Image> {
|
||||
fn codec_native_size(raw_data: &[u8]) -> Option<ISize> {
|
||||
let data = unsafe { skia::Data::new_bytes(raw_data) };
|
||||
let codec = Codec::from_data(&data)?;
|
||||
let image = Image::from_encoded(&data)?;
|
||||
|
||||
let mut dimensions = codec.dimensions();
|
||||
if codec.origin().swaps_width_height() {
|
||||
dimensions.width = codec.dimensions().height;
|
||||
dimensions.height = codec.dimensions().width;
|
||||
}
|
||||
Some(dimensions)
|
||||
}
|
||||
|
||||
let image_info = skia::ImageInfo::new_n32_premul(dimensions, None);
|
||||
/// Decode `raw_data` and upload a GPU texture whose longest side is at most
|
||||
/// `max_side` (or native size when `max_side` is 0 / larger than native).
|
||||
fn decode_image_to_max_side(
|
||||
context: &mut Box<DirectContext>,
|
||||
raw_data: &[u8],
|
||||
max_side: i32,
|
||||
) -> Option<(Image, ISize)> {
|
||||
let data = unsafe { skia::Data::new_bytes(raw_data) };
|
||||
let codec = Codec::from_data(&data)?;
|
||||
let encoded = Image::from_encoded(&data)?;
|
||||
|
||||
let mut native = codec.dimensions();
|
||||
if codec.origin().swaps_width_height() {
|
||||
native.width = codec.dimensions().height;
|
||||
native.height = codec.dimensions().width;
|
||||
}
|
||||
|
||||
let dst = fit_size(native, max_side);
|
||||
let image_info = skia::ImageInfo::new_n32_premul(dst, None);
|
||||
|
||||
let mut surface = surfaces::render_target(
|
||||
context,
|
||||
@ -154,13 +208,29 @@ fn decode_image(context: &mut Box<DirectContext>, raw_data: &[u8]) -> Option<Ima
|
||||
)?;
|
||||
|
||||
let dest_rect: MathRect =
|
||||
MathRect::from_xywh(0.0, 0.0, dimensions.width as f32, dimensions.height as f32);
|
||||
MathRect::from_xywh(0.0, 0.0, dst.width as f32, dst.height as f32);
|
||||
|
||||
surface
|
||||
.canvas()
|
||||
.draw_image_rect(&image, None, dest_rect, &skia::Paint::default());
|
||||
.draw_image_rect(&encoded, None, dest_rect, &skia::Paint::default());
|
||||
|
||||
Some(surface.image_snapshot())
|
||||
Some((surface.image_snapshot(), native))
|
||||
}
|
||||
|
||||
// Decode and upload to GPU at native size.
|
||||
fn decode_image(context: &mut Box<DirectContext>, raw_data: &[u8]) -> Option<Image> {
|
||||
decode_image_to_max_side(context, raw_data, 0).map(|(img, _)| img)
|
||||
}
|
||||
|
||||
fn raster_retained_bytes(raster: &RasterStored) -> usize {
|
||||
let mut bytes = raster.raw.len();
|
||||
if let Some(ref img) = raster.display {
|
||||
bytes += rgba_bytes(img);
|
||||
}
|
||||
if let Some(ref img) = raster.full {
|
||||
bytes += rgba_bytes(img);
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
// Size for SVGs without intrinsic dimensions nor a viewBox.
|
||||
@ -262,6 +332,22 @@ impl ImageStore {
|
||||
);
|
||||
}
|
||||
|
||||
fn recompute_entry_bytes(&mut self, key: (Uuid, bool)) {
|
||||
if let Some(entry) = self.images.get_mut(&key) {
|
||||
let new_bytes = match &entry.image {
|
||||
StoredImage::Raster(r) => raster_retained_bytes(r),
|
||||
StoredImage::Gpu(img) => rgba_bytes(img),
|
||||
StoredImage::Svg { .. } => entry.bytes, // keep prior encoded estimate
|
||||
StoredImage::Raw(raw) => raw.len(),
|
||||
};
|
||||
self.total_bytes = self
|
||||
.total_bytes
|
||||
.saturating_sub(entry.bytes)
|
||||
.saturating_add(new_bytes);
|
||||
entry.bytes = new_bytes;
|
||||
}
|
||||
}
|
||||
|
||||
/// Evicts least-recently-used images until the store retains at most
|
||||
/// `max_bytes`. Meant to be called by the headless exporter *between*
|
||||
/// requests, so an image can never disappear under a running render;
|
||||
@ -295,15 +381,22 @@ impl ImageStore {
|
||||
evicted
|
||||
}
|
||||
|
||||
/// Stores encoded image bytes. When `display_side` is set (shape @ 100% ×
|
||||
/// dpr), eagerly builds that GPU tier and keeps the full native decode
|
||||
/// lazy until deep zoom asks for it.
|
||||
pub fn add(
|
||||
&mut self,
|
||||
id: Uuid,
|
||||
is_thumbnail: bool,
|
||||
image_data: &[u8],
|
||||
display_side: Option<i32>,
|
||||
) -> crate::error::Result<()> {
|
||||
let key = (id, is_thumbnail);
|
||||
|
||||
if self.images.contains_key(&key) {
|
||||
if let Some(side) = display_side.filter(|s| *s > 0) {
|
||||
self.ensure_display_side(key, side);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@ -311,10 +404,8 @@ impl ImageStore {
|
||||
let bytes = raw_data.len();
|
||||
|
||||
match self.context.as_mut() {
|
||||
Some(context) => {
|
||||
if let Some(gpu_image) = decode_image(context, &raw_data) {
|
||||
self.insert_entry(key, StoredImage::Gpu(gpu_image), bytes);
|
||||
} else if let Some((dom, size)) = parse_svg(&raw_data) {
|
||||
Some(_) => {
|
||||
if let Some((dom, size)) = parse_svg(&raw_data) {
|
||||
self.insert_entry(
|
||||
key,
|
||||
StoredImage::Svg {
|
||||
@ -324,6 +415,42 @@ impl ImageStore {
|
||||
},
|
||||
bytes,
|
||||
);
|
||||
} else if let Some(native) = codec_native_size(&raw_data) {
|
||||
let mut raster = RasterStored {
|
||||
raw: raw_data,
|
||||
native,
|
||||
display: None,
|
||||
display_side: 0,
|
||||
full: None,
|
||||
};
|
||||
|
||||
// Thumbnails are already small: decode once at native.
|
||||
// Otherwise eagerly build the display tier when we know
|
||||
// the shape side at 100% zoom.
|
||||
let eager_side = if is_thumbnail {
|
||||
native.width.max(native.height)
|
||||
} else {
|
||||
display_side.unwrap_or(0).max(0)
|
||||
};
|
||||
|
||||
if eager_side > 0 {
|
||||
let target = fit_size(native, eager_side);
|
||||
let target_side = target.width.max(target.height);
|
||||
if let Some(context) = self.context.as_mut() {
|
||||
if let Some((img, _)) =
|
||||
decode_image_to_max_side(context, &raster.raw, target_side)
|
||||
{
|
||||
// Always park the eager decode in `display`.
|
||||
// `full` is reserved for a larger lazy promote.
|
||||
raster.display = Some(img);
|
||||
raster.display_side =
|
||||
target_side.min(native.width.max(native.height));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let retained = raster_retained_bytes(&raster);
|
||||
self.insert_entry(key, StoredImage::Raster(raster), retained);
|
||||
} else {
|
||||
// The lazy re-decode in `get_internal` only retries raster codecs,
|
||||
// so SVGs that fail to parse here stay raw.
|
||||
@ -385,13 +512,251 @@ impl ImageStore {
|
||||
self.images.contains_key(&(*id, is_thumbnail))
|
||||
}
|
||||
|
||||
pub fn get(&mut self, id: &Uuid) -> Option<&Image> {
|
||||
// Try to get full image first, fallback to thumbnail
|
||||
let has_full = self.images.contains_key(&(*id, false));
|
||||
if has_full {
|
||||
self.get_internal(id, false)
|
||||
/// Grow/create the display GPU tier so it covers at least `side` px
|
||||
/// (capped to native). Used when the shape grows while zoomed out.
|
||||
fn ensure_display_side(&mut self, key: (Uuid, bool), side: i32) {
|
||||
let Some(entry) = self.images.get_mut(&key) else {
|
||||
return;
|
||||
};
|
||||
let StoredImage::Raster(raster) = &mut entry.image else {
|
||||
return;
|
||||
};
|
||||
let native_side = raster.native.width.max(raster.native.height);
|
||||
let target = side.min(native_side).max(1);
|
||||
if raster.display_side >= target {
|
||||
return;
|
||||
}
|
||||
// If we already have full and target is native, display is redundant.
|
||||
if target >= native_side {
|
||||
if raster.full.is_none() && raster.display.is_none() {
|
||||
let raw = raster.raw.clone();
|
||||
let Some(context) = self.context.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if let Some((img, _)) = decode_image_to_max_side(context, &raw, 0) {
|
||||
if let Some(entry) = self.images.get_mut(&key) {
|
||||
if let StoredImage::Raster(raster) = &mut entry.image {
|
||||
// Native-sized tier lives in `display` so eviction
|
||||
// logic never drops the only GPU image.
|
||||
raster.display = Some(img);
|
||||
raster.display_side = native_side;
|
||||
raster.full = None;
|
||||
}
|
||||
}
|
||||
self.recompute_entry_bytes(key);
|
||||
}
|
||||
} else if let Some(entry) = self.images.get_mut(&key) {
|
||||
if let StoredImage::Raster(raster) = &mut entry.image {
|
||||
if raster.display.is_none() {
|
||||
if let Some(full) = raster.full.take() {
|
||||
raster.display = Some(full);
|
||||
}
|
||||
}
|
||||
raster.display_side = native_side;
|
||||
raster.full = None;
|
||||
}
|
||||
self.recompute_entry_bytes(key);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let raw = match self.images.get(&key) {
|
||||
Some(StoredEntry {
|
||||
image: StoredImage::Raster(r),
|
||||
..
|
||||
}) => r.raw.clone(),
|
||||
_ => return,
|
||||
};
|
||||
let Some(context) = self.context.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let Some((img, _)) = decode_image_to_max_side(context, &raw, target) else {
|
||||
return;
|
||||
};
|
||||
if let Some(entry) = self.images.get_mut(&key) {
|
||||
if let StoredImage::Raster(raster) = &mut entry.image {
|
||||
raster.display = Some(img);
|
||||
raster.display_side = target;
|
||||
}
|
||||
}
|
||||
self.recompute_entry_bytes(key);
|
||||
}
|
||||
|
||||
fn ensure_full(&mut self, key: (Uuid, bool)) -> bool {
|
||||
let raw = match self.images.get(&key) {
|
||||
Some(StoredEntry {
|
||||
image: StoredImage::Raster(r),
|
||||
..
|
||||
}) => {
|
||||
if r.full.is_some() {
|
||||
return true;
|
||||
}
|
||||
r.raw.clone()
|
||||
}
|
||||
_ => return false,
|
||||
};
|
||||
let Some(context) = self.context.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
let Some((img, _)) = decode_image_to_max_side(context, &raw, 0) else {
|
||||
return false;
|
||||
};
|
||||
if let Some(entry) = self.images.get_mut(&key) {
|
||||
if let StoredImage::Raster(raster) = &mut entry.image {
|
||||
raster.full = Some(img);
|
||||
}
|
||||
}
|
||||
self.recompute_entry_bytes(key);
|
||||
true
|
||||
}
|
||||
|
||||
fn drop_full_if_unneeded(&mut self, key: (Uuid, bool), needed_side: i32) {
|
||||
let should_drop = {
|
||||
let Some(entry) = self.images.get(&key) else {
|
||||
return;
|
||||
};
|
||||
let StoredImage::Raster(raster) = &entry.image else {
|
||||
return;
|
||||
};
|
||||
if raster.full.is_none() {
|
||||
return;
|
||||
}
|
||||
// Only evict full when a real display texture still covers the
|
||||
// request. If display was cleared because the tier is native-sized
|
||||
// (full is the only GPU image), dropping it leaves nothing to draw.
|
||||
raster.display.is_some()
|
||||
&& raster.display_side > 0
|
||||
&& needed_side <= raster.display_side
|
||||
};
|
||||
if !should_drop {
|
||||
return;
|
||||
}
|
||||
if let Some(entry) = self.images.get_mut(&key) {
|
||||
if let StoredImage::Raster(raster) = &mut entry.image {
|
||||
raster.full = None;
|
||||
}
|
||||
}
|
||||
self.recompute_entry_bytes(key);
|
||||
}
|
||||
|
||||
/// Picks display vs full for painting. `display_side` is shape@100%×dpr;
|
||||
/// `needed_side` is shape×view_scale (zoom×dpr).
|
||||
pub fn get_for_draw(
|
||||
&mut self,
|
||||
id: &Uuid,
|
||||
display_side: i32,
|
||||
needed_side: i32,
|
||||
) -> Option<&Image> {
|
||||
let key = if self.images.contains_key(&(*id, false)) {
|
||||
(*id, false)
|
||||
} else if self.images.contains_key(&(*id, true)) {
|
||||
(*id, true)
|
||||
} else {
|
||||
self.get_internal(id, true)
|
||||
return None;
|
||||
};
|
||||
|
||||
// Promote Raw → Raster on first draw if needed.
|
||||
if matches!(
|
||||
self.images.get(&key).map(|e| &e.image),
|
||||
Some(StoredImage::Raw(_))
|
||||
) {
|
||||
let raw = match self.images.remove(&key) {
|
||||
Some(StoredEntry {
|
||||
image: StoredImage::Raw(raw),
|
||||
bytes,
|
||||
last_used,
|
||||
}) => {
|
||||
self.total_bytes = self.total_bytes.saturating_sub(bytes);
|
||||
let _ = last_used;
|
||||
raw
|
||||
}
|
||||
Some(other) => {
|
||||
self.images.insert(key, other);
|
||||
return None;
|
||||
}
|
||||
None => return None,
|
||||
};
|
||||
if let Some(native) = codec_native_size(&raw) {
|
||||
let retained = raw.len();
|
||||
self.insert_entry(
|
||||
key,
|
||||
StoredImage::Raster(RasterStored {
|
||||
raw,
|
||||
native,
|
||||
display: None,
|
||||
display_side: 0,
|
||||
full: None,
|
||||
}),
|
||||
retained,
|
||||
);
|
||||
} else {
|
||||
self.insert_entry(key, StoredImage::Raw(raw), 0);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
if display_side > 0 {
|
||||
self.ensure_display_side(key, display_side);
|
||||
}
|
||||
|
||||
let use_full = {
|
||||
let entry = self.images.get(&key)?;
|
||||
match &entry.image {
|
||||
StoredImage::Raster(r) => {
|
||||
// Need full when display is missing or too small for needed.
|
||||
let display_covers = r.display.is_some()
|
||||
&& r.display_side > 0
|
||||
&& needed_side <= r.display_side;
|
||||
!display_covers
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
};
|
||||
|
||||
if use_full {
|
||||
self.ensure_full(key);
|
||||
} else {
|
||||
self.drop_full_if_unneeded(key, needed_side);
|
||||
}
|
||||
|
||||
if matches!(
|
||||
self.images.get(&key).map(|e| &e.image),
|
||||
Some(StoredImage::Svg { .. })
|
||||
) {
|
||||
return self.get_internal(id, key.1);
|
||||
}
|
||||
|
||||
let tick = self.next_tick();
|
||||
let entry = self.images.get(&key)?;
|
||||
entry.last_used.set(tick);
|
||||
match &entry.image {
|
||||
StoredImage::Raster(r) => {
|
||||
let prefer_display = r.display.is_some()
|
||||
&& r.display_side > 0
|
||||
&& needed_side <= r.display_side;
|
||||
if prefer_display {
|
||||
r.display.as_ref()
|
||||
} else {
|
||||
// Prefer full when needed; fall back to display if full
|
||||
// decode failed (e.g. GPU limit) so the shape stays visible.
|
||||
r.full.as_ref().or(r.display.as_ref())
|
||||
}
|
||||
}
|
||||
StoredImage::Gpu(img) => Some(img),
|
||||
StoredImage::Svg { .. } => None, // handled above
|
||||
StoredImage::Raw(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(&mut self, id: &Uuid) -> Option<&Image> {
|
||||
// Legacy callers without LOD: prefer full when present, else display.
|
||||
let has_full = self.images.contains_key(&(*id, false));
|
||||
let key_thumb = !has_full;
|
||||
if has_full {
|
||||
// Request a huge needed side so full is materialized when possible.
|
||||
self.get_for_draw(id, 0, i32::MAX)
|
||||
} else {
|
||||
self.get_internal(id, key_thumb)
|
||||
}
|
||||
}
|
||||
|
||||
@ -417,6 +782,10 @@ impl ImageStore {
|
||||
let data = unsafe { skia::Data::new_bytes(raw_data) };
|
||||
Image::from_encoded(&data)
|
||||
}
|
||||
StoredImage::Raster(r) => {
|
||||
let data = unsafe { skia::Data::new_bytes(&r.raw) };
|
||||
Image::from_encoded(&data)
|
||||
}
|
||||
StoredImage::Gpu(img) => Some(img.clone()),
|
||||
StoredImage::Svg { dom, size, .. } => {
|
||||
// No GPU context in the headless path: rasterize on a CPU
|
||||
@ -447,33 +816,67 @@ impl ImageStore {
|
||||
let key = (*id, is_thumbnail);
|
||||
let tick = self.tick.get() + 1;
|
||||
self.tick.set(tick);
|
||||
// Use entry API to mutate the HashMap in-place if needed
|
||||
if let Some(entry) = self.images.get_mut(&key) {
|
||||
entry.last_used.set(tick);
|
||||
let image = &mut entry.image;
|
||||
match image {
|
||||
StoredImage::Gpu(ref img) => Some(img),
|
||||
StoredImage::Raw(raw_data) => {
|
||||
let context = self.context.as_mut()?;
|
||||
let gpu_image = decode_image(context, raw_data)?;
|
||||
*image = StoredImage::Gpu(gpu_image);
|
||||
|
||||
if let StoredImage::Gpu(ref img) = image {
|
||||
Some(img)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
StoredImage::Svg { dom, size, raster } => {
|
||||
if raster.is_none() {
|
||||
let context = self.context.as_mut()?;
|
||||
*raster = rasterize_svg(context, dom, *size);
|
||||
}
|
||||
raster.as_ref()
|
||||
let needs_full_decode = matches!(
|
||||
self.images.get(&key).map(|e| &e.image),
|
||||
Some(StoredImage::Raster(r)) if r.full.is_none() && r.display.is_none()
|
||||
);
|
||||
let needs_gpu_from_raw = matches!(
|
||||
self.images.get(&key).map(|e| &e.image),
|
||||
Some(StoredImage::Raw(_))
|
||||
);
|
||||
let needs_svg_raster = matches!(
|
||||
self.images.get(&key).map(|e| &e.image),
|
||||
Some(StoredImage::Svg { raster: None, .. })
|
||||
);
|
||||
|
||||
if needs_full_decode {
|
||||
let raw = match self.images.get(&key) {
|
||||
Some(StoredEntry {
|
||||
image: StoredImage::Raster(r),
|
||||
..
|
||||
}) => r.raw.clone(),
|
||||
_ => return None,
|
||||
};
|
||||
let context = self.context.as_mut()?;
|
||||
let gpu_image = decode_image(context, &raw)?;
|
||||
if let Some(entry) = self.images.get_mut(&key) {
|
||||
if let StoredImage::Raster(r) = &mut entry.image {
|
||||
r.full = Some(gpu_image);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
self.recompute_entry_bytes(key);
|
||||
} else if needs_gpu_from_raw {
|
||||
let raw = match self.images.get(&key) {
|
||||
Some(StoredEntry {
|
||||
image: StoredImage::Raw(raw),
|
||||
..
|
||||
}) => raw.clone(),
|
||||
_ => return None,
|
||||
};
|
||||
let context = self.context.as_mut()?;
|
||||
let gpu_image = decode_image(context, &raw)?;
|
||||
if let Some(entry) = self.images.get_mut(&key) {
|
||||
entry.image = StoredImage::Gpu(gpu_image);
|
||||
}
|
||||
} else if needs_svg_raster {
|
||||
let ImageStore {
|
||||
context, images, ..
|
||||
} = self;
|
||||
let context = context.as_mut()?;
|
||||
let entry = images.get_mut(&key)?;
|
||||
if let StoredImage::Svg { dom, size, raster } = &mut entry.image {
|
||||
*raster = rasterize_svg(context, dom, *size);
|
||||
}
|
||||
}
|
||||
|
||||
let entry = self.images.get(&key)?;
|
||||
entry.last_used.set(tick);
|
||||
match &entry.image {
|
||||
StoredImage::Gpu(img) => Some(img),
|
||||
StoredImage::Raster(r) => r.full.as_ref().or(r.display.as_ref()),
|
||||
StoredImage::Svg { raster, .. } => raster.as_ref(),
|
||||
StoredImage::Raw(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -568,7 +568,15 @@ fn draw_image_stroke_in_container(
|
||||
} else {
|
||||
stroke
|
||||
};
|
||||
let Some(image) = get_resources().images.get(&image_fill.id()) else {
|
||||
let view_scale = render_state.get_view_scale();
|
||||
let dpr = render_state.options.dpr.max(1.0);
|
||||
let display_side = super::images::shape_side_px(&shape.selrect, dpr);
|
||||
let needed_side = super::images::shape_side_px(&shape.selrect, view_scale);
|
||||
let Some(image) = get_resources().images.get_for_draw(
|
||||
&image_fill.id(),
|
||||
display_side,
|
||||
needed_side,
|
||||
) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
|
||||
@ -127,10 +127,22 @@ pub extern "C" fn store_image() -> Result<()> {
|
||||
let image_bytes = &bytes[IMAGE_HEADER_SIZE..];
|
||||
|
||||
with_state!(state, {
|
||||
if let Err(msg) = get_resources()
|
||||
.images
|
||||
.add(ids.image_id, is_thumbnail, image_bytes)
|
||||
{
|
||||
let display_side = if is_thumbnail {
|
||||
None
|
||||
} else {
|
||||
state.shapes.get(&ids.shape_id).map(|shape| {
|
||||
// Display tier = shape size at 100% zoom in device pixels (× dpr).
|
||||
let dpr = crate::get_render_state().options.dpr.max(1.0);
|
||||
crate::render::images::shape_side_px(&shape.selrect, dpr)
|
||||
})
|
||||
};
|
||||
|
||||
if let Err(msg) = get_resources().images.add(
|
||||
ids.image_id,
|
||||
is_thumbnail,
|
||||
image_bytes,
|
||||
display_side,
|
||||
) {
|
||||
eprintln!("{}", msg);
|
||||
}
|
||||
touch_shapes_with_image(state, ids.image_id);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user