Skip to content

Commit 71c4987

Browse files
committed
Implement preview workflow
1 parent d065663 commit 71c4987

File tree

9 files changed

+1370
-35
lines changed

9 files changed

+1370
-35
lines changed

crates/bevy_asset_preview/Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ version = "0.1.0"
44
edition = "2024"
55

66
[dependencies]
7-
bevy.workspace = true
8-
image = "0.25"
7+
bevy = { workspace = true, features = ["webp"] }
8+
image = { version = "0.25", features = ["webp"] }
99

1010
[dev-dependencies]
1111
tempfile = "3"

crates/bevy_asset_preview/src/asset/saver.rs

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,15 @@ pub fn save_image<'a>(
8383

8484
let task_pool = IoTaskPool::get();
8585
let target_path_clone = target_path.clone();
86-
let target_path_for_writer = target_path.path().to_path_buf();
86+
// Ensure the file extension is .webp for WebP format
87+
let mut target_path_for_writer = target_path.path().to_path_buf();
88+
if let Some(ext) = target_path_for_writer.extension() {
89+
if ext.to_str() != Some("webp") {
90+
target_path_for_writer.set_extension("webp");
91+
}
92+
} else {
93+
target_path_for_writer.set_extension("webp");
94+
}
8795

8896
task_pool.spawn(async move {
8997
// Create directory first
@@ -97,12 +105,12 @@ pub fn save_image<'a>(
97105

98106
// Encode PNG directly to memory
99107
let mut cursor = Cursor::new(Vec::new());
100-
match rgba_image.write_to(&mut cursor, image::ImageFormat::Png) {
108+
match rgba_image.write_to(&mut cursor, image::ImageFormat::WebP) {
101109
Ok(_) => {
102-
let png_bytes = cursor.into_inner();
110+
let webp_bytes = cursor.into_inner();
103111
// Write via AssetWriter (atomic operation)
104112
match writer
105-
.write_bytes(&target_path_for_writer, &png_bytes)
113+
.write_bytes(&target_path_for_writer, &webp_bytes)
106114
.await
107115
{
108116
Ok(_) => {
@@ -118,7 +126,7 @@ pub fn save_image<'a>(
118126
}
119127
}
120128
Err(e) => {
121-
let error = format!("Failed to encode image to PNG: {:?}", e);
129+
let error = format!("Failed to encode image to WebP: {:?}", e);
122130
bevy::log::error!("{}", error);
123131
Err(error)
124132
}

crates/bevy_asset_preview/src/lib.rs

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
mod asset;
2+
mod preview;
23
mod ui;
34

45
pub use asset::*;
6+
pub use preview::*;
57
pub use ui::*;
68

79
use bevy::prelude::*;
@@ -13,10 +15,41 @@ use bevy::prelude::*;
1315
/// So long as the assets are unchanged, the previews will be cached and will not need to be re-rendered.
1416
/// In theory this can be done passively in the background, and the previews will be ready when the user needs them.
1517
18+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
19+
pub enum AssetPreviewType {
20+
Image,
21+
GLTF,
22+
Scene,
23+
Other,
24+
}
25+
1626
pub struct AssetPreviewPlugin;
1727

1828
impl Plugin for AssetPreviewPlugin {
19-
fn build(&self, _app: &mut App) {
29+
fn build(&self, app: &mut App) {
30+
// Initialize resources
31+
app.init_resource::<AssetLoader>();
32+
app.init_resource::<PreviewCache>();
33+
34+
// Register events
35+
app.add_event::<asset::AssetLoadCompleted>();
36+
app.add_event::<asset::AssetLoadFailed>();
37+
app.add_event::<asset::AssetHotReloaded>();
38+
39+
// Register systems
40+
// Process preview requests and submit to loader
41+
app.add_systems(Update, ui::preview_handler);
42+
43+
// Process load queue (starts new load tasks)
44+
app.add_systems(Update, asset::process_load_queue);
45+
46+
// Handle asset events (completion, failures, hot reloads)
47+
app.add_systems(Update, asset::handle_asset_events);
2048

49+
// Handle preview load completion and update UI
50+
app.add_systems(
51+
Update,
52+
ui::handle_preview_load_completed.after(asset::handle_asset_events),
53+
);
2154
}
2255
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
use bevy::{
2+
asset::{AssetId, AssetPath},
3+
image::Image,
4+
platform::collections::HashMap,
5+
prelude::{Handle, Resource},
6+
};
7+
8+
/// Cache entry for a preview image.
9+
#[derive(Clone, Debug)]
10+
pub struct PreviewCacheEntry {
11+
/// The preview image handle.
12+
pub image_handle: Handle<Image>,
13+
/// The asset ID that this preview is for.
14+
pub asset_id: AssetId<Image>,
15+
/// Timestamp when the preview was generated (for cache invalidation).
16+
pub timestamp: u64,
17+
}
18+
19+
/// Cache for preview images to avoid re-rendering unchanged assets.
20+
#[derive(Resource, Default)]
21+
pub struct PreviewCache {
22+
/// Maps asset path to cache entry.
23+
path_cache: HashMap<AssetPath<'static>, PreviewCacheEntry>,
24+
/// Maps asset ID to cache entry.
25+
id_cache: HashMap<AssetId<Image>, PreviewCacheEntry>,
26+
}
27+
28+
impl PreviewCache {
29+
/// Creates a new empty cache.
30+
pub fn new() -> Self {
31+
Self {
32+
path_cache: HashMap::new(),
33+
id_cache: HashMap::new(),
34+
}
35+
}
36+
37+
/// Gets a cached preview by asset path.
38+
pub fn get_by_path<'a>(&self, path: &AssetPath<'a>) -> Option<&PreviewCacheEntry> {
39+
// Convert to owned path for lookup
40+
let owned_path: AssetPath<'static> = path.clone().into_owned();
41+
self.path_cache.get(&owned_path)
42+
}
43+
44+
/// Gets a cached preview by asset ID.
45+
pub fn get_by_id(&self, asset_id: AssetId<Image>) -> Option<&PreviewCacheEntry> {
46+
self.id_cache.get(&asset_id)
47+
}
48+
49+
/// Inserts a preview into the cache.
50+
pub fn insert<'a>(
51+
&mut self,
52+
path: impl Into<AssetPath<'a>>,
53+
asset_id: AssetId<Image>,
54+
image_handle: Handle<Image>,
55+
timestamp: u64,
56+
) {
57+
let path: AssetPath<'static> = path.into().into_owned();
58+
let entry = PreviewCacheEntry {
59+
image_handle,
60+
asset_id,
61+
timestamp,
62+
};
63+
self.path_cache.insert(path.clone(), entry.clone());
64+
self.id_cache.insert(asset_id, entry);
65+
}
66+
67+
/// Removes a preview from the cache by path.
68+
pub fn remove_by_path<'a>(&mut self, path: &AssetPath<'a>) -> Option<PreviewCacheEntry> {
69+
// Convert to owned path for lookup
70+
let owned_path: AssetPath<'static> = path.clone().into_owned();
71+
if let Some(entry) = self.path_cache.remove(&owned_path) {
72+
self.id_cache.remove(&entry.asset_id);
73+
Some(entry)
74+
} else {
75+
None
76+
}
77+
}
78+
79+
/// Removes a preview from the cache by asset ID.
80+
pub fn remove_by_id(&mut self, asset_id: AssetId<Image>) -> Option<PreviewCacheEntry> {
81+
if let Some(entry) = self.id_cache.remove(&asset_id) {
82+
// Find and remove from path cache
83+
self.path_cache.retain(|_, e| e.asset_id != asset_id);
84+
Some(entry)
85+
} else {
86+
None
87+
}
88+
}
89+
90+
/// Clears all cached previews.
91+
pub fn clear(&mut self) {
92+
self.path_cache.clear();
93+
self.id_cache.clear();
94+
}
95+
96+
/// Returns the number of cached previews.
97+
pub fn len(&self) -> usize {
98+
self.path_cache.len()
99+
}
100+
101+
/// Checks if the cache is empty.
102+
pub fn is_empty(&self) -> bool {
103+
self.path_cache.is_empty()
104+
}
105+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
mod cache;
2+
mod systems;
3+
4+
use bevy::{asset::RenderAssetUsages, image::Image};
5+
pub use cache::{PreviewCache, PreviewCacheEntry};
6+
pub use systems::{
7+
PendingPreviewRequest, PreviewFailed, PreviewReady, PreviewTaskManager,
8+
handle_image_preview_events, request_image_preview,
9+
};
10+
11+
/// Maximum preview size for 2D images (256x256).
12+
const MAX_PREVIEW_SIZE: u32 = 256;
13+
14+
/// Resizes an image to preview size if it's larger than the maximum.
15+
/// Returns a new compressed image, or None if the image is already small enough.
16+
pub fn compress_image_for_preview(image: &Image) -> Option<Image> {
17+
let width = image.width();
18+
let height = image.height();
19+
20+
// If image is already small enough, return None (use original)
21+
if width <= MAX_PREVIEW_SIZE && height <= MAX_PREVIEW_SIZE {
22+
return None;
23+
}
24+
25+
// Calculate new size maintaining aspect ratio
26+
let (new_width, new_height) = if width > height {
27+
(
28+
MAX_PREVIEW_SIZE,
29+
(height as f32 * MAX_PREVIEW_SIZE as f32 / width as f32) as u32,
30+
)
31+
} else {
32+
(
33+
(width as f32 * MAX_PREVIEW_SIZE as f32 / height as f32) as u32,
34+
MAX_PREVIEW_SIZE,
35+
)
36+
};
37+
38+
// Convert to dynamic image for resizing
39+
let dynamic_image = match image.clone().try_into_dynamic() {
40+
Ok(img) => img,
41+
Err(_) => return None,
42+
};
43+
44+
// Resize using high-quality filter
45+
let resized =
46+
dynamic_image.resize_exact(new_width, new_height, image::imageops::FilterType::Lanczos3);
47+
48+
// Convert back to Image
49+
Some(Image::from_dynamic(
50+
resized,
51+
true, // is_srgb
52+
RenderAssetUsages::RENDER_WORLD,
53+
))
54+
}

0 commit comments

Comments
 (0)