1use std::io::Cursor;
2
3use bevy_asset::{saver::AssetSaver, AssetPath, AsyncWriteExt};
4use bevy_reflect::TypePath;
5use image::{write_buffer_with_format, ExtendedColorType};
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8use wgpu_types::TextureFormat;
9
10use crate::{Image, ImageFormat, ImageFormatSetting, ImageLoader, ImageLoaderSettings};
11
12#[derive(Clone, TypePath)]
21pub struct ImageSaver;
22
23impl AssetSaver for ImageSaver {
24 type Asset = Image;
25 type Error = SaveImageError;
26 type OutputLoader = ImageLoader;
27 type Settings = ImageSaverSettings;
28
29 async fn save(
30 &self,
31 _writer: &mut bevy_asset::io::Writer,
32 asset: bevy_asset::saver::SavedAsset<'_, '_, Self::Asset>,
33 settings: &Self::Settings,
34 asset_path: AssetPath<'_>,
35 ) -> Result<ImageLoaderSettings, Self::Error> {
36 let format = match settings.format {
37 SaveImageFormatSetting::Format(format) => format,
38 SaveImageFormatSetting::FromExtension => match asset_path.get_extension() {
39 None => return Err(SaveImageError::MissingExtension(asset_path.into_owned())),
40 Some(extension) => ImageFormat::from_extension(extension)
41 .ok_or_else(|| SaveImageError::UnknownExtension(extension.to_owned()))?,
42 },
43 };
44
45 let Some(_asset_data) = asset.data.as_ref() else {
46 return Err(SaveImageError::ImageMissingData);
47 };
48
49 let (image_crate_format, color_type, is_srgb): (_, ExtendedColorType, _) = match format {
51 #[cfg(feature = "png")]
52 ImageFormat::Png => match asset.texture_descriptor.format {
53 TextureFormat::R8Unorm => (image::ImageFormat::Png, ExtendedColorType::L8, false),
54 TextureFormat::Rgba8Unorm => {
55 (image::ImageFormat::Png, ExtendedColorType::Rgba8, false)
56 }
57 TextureFormat::Rgba8UnormSrgb => {
58 (image::ImageFormat::Png, ExtendedColorType::Rgba8, true)
59 }
60 _ => {
61 return Err(SaveImageError::UnsupportedSaveColorTypeForFormat(
62 ImageFormat::Png,
63 asset.texture_descriptor.format,
64 ))
65 }
66 },
67 #[expect(
69 clippy::allow_attributes,
70 reason = "`unreachable_patterns` may not always lint"
71 )]
72 #[allow(
73 unreachable_patterns,
74 reason = "The wildcard pattern will be unreachable if only save-able formats are enabled"
75 )]
76 _ => return Err(SaveImageError::UnsupportedFormat(format)),
77 };
78
79 #[expect(clippy::allow_attributes, reason = "this lint only sometimes lints")]
80 #[allow(
81 unreachable_code,
82 reason = "this code is unreachable if none of the supported save formats are enabled"
83 )]
84 let mut bytes = vec![];
85 write_buffer_with_format(
86 &mut Cursor::new(&mut bytes),
87 _asset_data,
88 asset.width(),
89 asset.height(),
90 color_type,
91 image_crate_format,
92 )?;
93
94 _writer.write_all(&bytes).await?;
95
96 Ok(ImageLoaderSettings {
97 format: ImageFormatSetting::Format(format),
98 texture_format: None,
99 is_srgb,
100 sampler: asset.sampler.clone(),
101 asset_usage: asset.asset_usage,
102 array_layout: None,
103 expand_grayscale: false,
104 })
105 }
106}
107
108#[derive(Serialize, Deserialize, Default, Clone, Debug)]
110pub struct ImageSaverSettings {
111 pub format: SaveImageFormatSetting,
113}
114
115#[derive(Serialize, Deserialize, Default, Clone, Copy, Debug)]
117pub enum SaveImageFormatSetting {
118 #[default]
120 FromExtension,
121 Format(ImageFormat),
123}
124
125#[derive(Error, Debug)]
127pub enum SaveImageError {
128 #[error("SaveImageFormatSetting::FromExtension was set, but the asset path \"{0}\" has no extension")]
130 MissingExtension(AssetPath<'static>),
131 #[error("could not determine asset format for extension \"{0}\"")]
134 UnknownExtension(String),
135 #[error("the provided image does not contain any pixel data. Its data may live on the GPU (which we can't save out) due to `RenderAssetUsages`")]
138 ImageMissingData,
139 #[error("the requested file format {0:?} is not supported for saving")]
141 UnsupportedFormat(ImageFormat),
142 #[error("the image uses a texture format \"{1:?}\" that is not supported for saving by the image format \"{0:?}\"")]
144 UnsupportedSaveColorTypeForFormat(ImageFormat, TextureFormat),
145 #[error(transparent)]
147 ImageError(#[from] image::ImageError),
148 #[error(transparent)]
150 IoError(#[from] std::io::Error),
151}
152
153#[cfg(test)]
154mod tests {
155 use std::path::Path;
156
157 use bevy_app::{App, TaskPoolPlugin};
158 use bevy_asset::{
159 io::{
160 memory::{Dir, MemoryAssetReader, MemoryAssetWriter},
161 AssetSourceBuilder, AssetSourceId,
162 },
163 saver::{save_using_saver, SavedAsset},
164 AssetApp, AssetPath, AssetPlugin, AssetServer, Assets, RenderAssetUsages,
165 };
166 use bevy_color::Srgba;
167 use bevy_ecs::world::World;
168 use bevy_math::UVec2;
169 use bevy_platform::future::block_on;
170 use wgpu_types::TextureFormat;
171
172 use crate::{
173 CompressedImageFormats, Image, ImageLoader, ImageSaver, ImageSaverSettings,
174 TextureFormatPixelInfo,
175 };
176
177 fn create_app() -> (App, Dir) {
178 let mut app = App::new();
179 let dir = Dir::default();
180 let dir_clone_1 = dir.clone();
181 let dir_clone_2 = dir.clone();
182 app.register_asset_source(
183 AssetSourceId::Default,
184 AssetSourceBuilder::new(move || {
185 Box::new(MemoryAssetReader {
186 root: dir_clone_1.clone(),
187 })
188 })
189 .with_writer(move |_| {
190 Some(Box::new(MemoryAssetWriter {
191 root: dir_clone_2.clone(),
192 }))
193 }),
194 )
195 .add_plugins((
196 TaskPoolPlugin::default(),
197 AssetPlugin {
198 watch_for_changes_override: Some(false),
199 use_asset_processor_override: Some(false),
200 ..Default::default()
201 },
202 ))
203 .init_asset::<Image>()
204 .register_asset_loader(ImageLoader::new(CompressedImageFormats::empty()));
205
206 (app, dir)
207 }
208
209 fn run_app_until(app: &mut App, mut predicate: impl FnMut(&mut World) -> Option<()>) {
210 const LARGE_ITERATION_COUNT: usize = 10000;
211 for _ in 0..LARGE_ITERATION_COUNT {
212 app.update();
213 if predicate(app.world_mut()).is_some() {
214 return;
215 }
216 }
217
218 panic!("Ran out of loops to return `Some` from `predicate`");
219 }
220
221 #[expect(clippy::allow_attributes, reason = "only occasionally unused")]
222 #[allow(unused, reason = "only used for feature-flagged image formats")]
223 fn roundtrip_for_type(file_name: &str, color_type: TextureFormat) {
224 let (mut app, dir) = create_app();
225 let asset_server = app.world().resource::<AssetServer>().clone();
226
227 let asset_path = AssetPath::from_path(Path::new(file_name));
228
229 const WIDTH: u32 = 5;
230 let mut image = Image::new(
231 wgpu_types::Extent3d {
232 width: WIDTH,
233 height: WIDTH,
234 depth_or_array_layers: 1,
235 },
236 wgpu_types::TextureDimension::D2,
237 vec![0; color_type.pixel_size().unwrap() * WIDTH as usize * WIDTH as usize],
238 color_type,
239 RenderAssetUsages::all(),
240 );
241 for y in 0..WIDTH {
242 for x in 0..WIDTH {
243 image
244 .set_color_at(
245 x,
246 y,
247 Srgba::new(
248 (x + 1) as f32 / WIDTH as f32,
249 (y + 1) as f32 / WIDTH as f32,
250 (x + y + 2) as f32 / (2 * WIDTH) as f32,
251 1.0,
252 )
253 .into(),
254 )
255 .unwrap();
256 }
257 }
258
259 {
260 let asset_server = asset_server.clone();
261 let image = image.clone();
262 let asset_path = asset_path.clone_owned();
263 block_on(async move {
264 let saved_asset = SavedAsset::from_asset(&image);
265 save_using_saver(
266 asset_server,
267 &ImageSaver,
268 &asset_path,
269 saved_asset,
270 &ImageSaverSettings::default(),
271 )
272 .await
273 })
274 .unwrap();
275 }
276
277 assert!(dir.get_asset(asset_path.path()).is_some());
278
279 let handle = asset_server.load::<Image>(asset_path);
280 run_app_until(&mut app, |_| asset_server.is_loaded(&handle).then_some(()));
281
282 let loaded_image = app
283 .world()
284 .resource::<Assets<Image>>()
285 .get(&handle)
286 .unwrap();
287
288 assert_eq!(loaded_image.size(), UVec2::new(WIDTH, WIDTH));
289 assert_eq!(loaded_image.texture_descriptor.format, color_type);
290 let compare_images = 'compare_images: {
291 for y in 0..WIDTH {
292 for x in 0..WIDTH {
293 if image.get_color_at(x, y).unwrap() != loaded_image.get_color_at(x, y).unwrap()
294 {
295 break 'compare_images Err((x, y));
296 }
297 }
298 }
299 Ok(())
300 };
301
302 if let Err((x, y)) = compare_images {
303 fn image_to_string(image: &Image) -> String {
304 (0..WIDTH)
305 .map(|y| {
306 (0..WIDTH)
307 .map(|x| {
308 let color = image.get_color_at(x, y).unwrap().to_srgba();
309 format!(
310 "({},{},{})",
311 (color.red * 255.0) as u32,
312 (color.green * 255.0) as u32,
313 (color.blue * 255.0) as u32,
314 )
315 })
316 .collect::<Vec<_>>()
317 .join(" ")
318 })
319 .collect::<Vec<_>>()
320 .join("\n")
321 }
322 panic!(
323 "Mismatch in color at ({x}, {y})\nleft:\n{}\nright:\n{}",
324 image_to_string(loaded_image),
325 image_to_string(&image)
326 );
327 }
328 }
329
330 #[cfg(feature = "png")]
331 mod png_tests {
332 use super::*;
333
334 #[test]
335 fn roundtrip_png_r8_unorm() {
336 roundtrip_for_type("image.png", TextureFormat::R8Unorm);
337 }
338 #[test]
339 fn roundtrip_png_rgba8_unorm_srgb() {
340 roundtrip_for_type("image.png", TextureFormat::Rgba8UnormSrgb);
341 }
342 #[test]
343 fn roundtrip_png_rgba8_unorm() {
344 roundtrip_for_type("image.png", TextureFormat::Rgba8Unorm);
345 }
346
347 #[test]
348 fn load_grayscale_png() {
349 let (mut app, dir) = create_app();
350 let asset_server = app.world().resource::<AssetServer>().clone();
351
352 let mut png = Vec::new();
353 image::DynamicImage::ImageLuma8(
354 image::GrayImage::from_raw(2, 2, vec![10, 20, 30, 40]).unwrap(),
355 )
356 .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
357 .unwrap();
358 dir.insert_asset(Path::new("gray.png"), png.clone());
359 dir.insert_asset(Path::new("gray_r8.png"), png);
360
361 let expanded = asset_server.load::<Image>("gray.png");
362 let r8 = asset_server
363 .load_builder()
364 .with_settings(|s: &mut crate::ImageLoaderSettings| {
365 s.texture_format = Some(TextureFormat::R8Unorm);
366 })
367 .load::<Image>("gray_r8.png");
368 run_app_until(&mut app, |_| {
369 (asset_server.is_loaded(&expanded) && asset_server.is_loaded(&r8)).then_some(())
370 });
371
372 let images = app.world().resource::<Assets<Image>>();
373 let expanded = images.get(&expanded).unwrap();
374 assert_eq!(
375 expanded.texture_descriptor.format,
376 TextureFormat::Rgba8UnormSrgb
377 );
378 assert_eq!(expanded.data.as_ref().unwrap().len(), 16);
379 let r8 = images.get(&r8).unwrap();
380 assert_eq!(r8.texture_descriptor.format, TextureFormat::R8Unorm);
381 assert_eq!(r8.data.as_deref(), Some(&[10, 20, 30, 40][..]));
382 }
383 }
384}