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,
101 is_srgb,
102 sampler: asset.sampler.clone(),
103 asset_usage: asset.asset_usage,
104 array_layout: None,
105 })
106 }
107}
108
109#[derive(Serialize, Deserialize, Default, Clone, Debug)]
111pub struct ImageSaverSettings {
112 pub format: SaveImageFormatSetting,
114}
115
116#[derive(Serialize, Deserialize, Default, Clone, Copy, Debug)]
118pub enum SaveImageFormatSetting {
119 #[default]
121 FromExtension,
122 Format(ImageFormat),
124}
125
126#[derive(Error, Debug)]
128pub enum SaveImageError {
129 #[error("SaveImageFormatSetting::FromExtension was set, but the asset path \"{0}\" has no extension")]
131 MissingExtension(AssetPath<'static>),
132 #[error("could not determine asset format for extension \"{0}\"")]
135 UnknownExtension(String),
136 #[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`")]
139 ImageMissingData,
140 #[error("the requested file format {0:?} is not supported for saving")]
142 UnsupportedFormat(ImageFormat),
143 #[error("the image uses a texture format \"{1:?}\" that is not supported for saving by the image format \"{0:?}\"")]
145 UnsupportedSaveColorTypeForFormat(ImageFormat, TextureFormat),
146 #[error(transparent)]
148 ImageError(#[from] image::ImageError),
149 #[error(transparent)]
151 IoError(#[from] std::io::Error),
152}
153
154#[cfg(test)]
155mod tests {
156 use std::path::Path;
157
158 use bevy_app::{App, TaskPoolPlugin};
159 use bevy_asset::{
160 io::{
161 memory::{Dir, MemoryAssetReader, MemoryAssetWriter},
162 AssetSourceBuilder, AssetSourceId,
163 },
164 saver::{save_using_saver, SavedAsset},
165 AssetApp, AssetPath, AssetPlugin, AssetServer, Assets, RenderAssetUsages,
166 };
167 use bevy_color::Srgba;
168 use bevy_ecs::world::World;
169 use bevy_math::UVec2;
170 use bevy_platform::future::block_on;
171 use wgpu_types::TextureFormat;
172
173 use crate::{
174 CompressedImageFormats, Image, ImageLoader, ImageSaver, ImageSaverSettings,
175 TextureFormatPixelInfo,
176 };
177
178 fn create_app() -> (App, Dir) {
179 let mut app = App::new();
180 let dir = Dir::default();
181 let dir_clone_1 = dir.clone();
182 let dir_clone_2 = dir.clone();
183 app.register_asset_source(
184 AssetSourceId::Default,
185 AssetSourceBuilder::new(move || {
186 Box::new(MemoryAssetReader {
187 root: dir_clone_1.clone(),
188 })
189 })
190 .with_writer(move |_| {
191 Some(Box::new(MemoryAssetWriter {
192 root: dir_clone_2.clone(),
193 }))
194 }),
195 )
196 .add_plugins((
197 TaskPoolPlugin::default(),
198 AssetPlugin {
199 watch_for_changes_override: Some(false),
200 use_asset_processor_override: Some(false),
201 ..Default::default()
202 },
203 ))
204 .init_asset::<Image>()
205 .register_asset_loader(ImageLoader::new(CompressedImageFormats::empty()));
206
207 (app, dir)
208 }
209
210 fn run_app_until(app: &mut App, mut predicate: impl FnMut(&mut World) -> Option<()>) {
211 const LARGE_ITERATION_COUNT: usize = 10000;
212 for _ in 0..LARGE_ITERATION_COUNT {
213 app.update();
214 if predicate(app.world_mut()).is_some() {
215 return;
216 }
217 }
218
219 panic!("Ran out of loops to return `Some` from `predicate`");
220 }
221
222 #[expect(clippy::allow_attributes, reason = "only occasionally unused")]
223 #[allow(unused, reason = "only used for feature-flagged image formats")]
224 fn roundtrip_for_type(file_name: &str, color_type: TextureFormat) {
225 let (mut app, dir) = create_app();
226 let asset_server = app.world().resource::<AssetServer>().clone();
227
228 let asset_path = AssetPath::from_path(Path::new(file_name));
229
230 const WIDTH: u32 = 5;
231 let mut image = Image::new(
232 wgpu_types::Extent3d {
233 width: WIDTH,
234 height: WIDTH,
235 depth_or_array_layers: 1,
236 },
237 wgpu_types::TextureDimension::D2,
238 vec![0; color_type.pixel_size().unwrap() * WIDTH as usize * WIDTH as usize],
239 color_type,
240 RenderAssetUsages::all(),
241 );
242 for y in 0..WIDTH {
243 for x in 0..WIDTH {
244 image
245 .set_color_at(
246 x,
247 y,
248 Srgba::new(
249 (x + 1) as f32 / WIDTH as f32,
250 (y + 1) as f32 / WIDTH as f32,
251 (x + y + 2) as f32 / (2 * WIDTH) as f32,
252 1.0,
253 )
254 .into(),
255 )
256 .unwrap();
257 }
258 }
259
260 {
261 let asset_server = asset_server.clone();
262 let image = image.clone();
263 let asset_path = asset_path.clone_owned();
264 block_on(async move {
265 let saved_asset = SavedAsset::from_asset(&image);
266 save_using_saver(
267 asset_server,
268 &ImageSaver,
269 &asset_path,
270 saved_asset,
271 &ImageSaverSettings::default(),
272 )
273 .await
274 })
275 .unwrap();
276 }
277
278 assert!(dir.get_asset(asset_path.path()).is_some());
279
280 let handle = asset_server.load::<Image>(asset_path);
281 run_app_until(&mut app, |_| asset_server.is_loaded(&handle).then_some(()));
282
283 let loaded_image = app
284 .world()
285 .resource::<Assets<Image>>()
286 .get(&handle)
287 .unwrap();
288
289 assert_eq!(loaded_image.size(), UVec2::new(WIDTH, WIDTH));
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}